From 27306d6e3f6f3dda09914e078151a8d07e111869 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 26 Oct 2021 15:27:19 +0200 Subject: [PATCH 01/39] refactor: Create BaseTypedRepresentationConverter --- src/index.ts | 1 + .../BaseTypedRepresentationConverter.ts | 76 +++++++++++++++++++ .../ContainerToTemplateConverter.ts | 4 +- .../conversion/ErrorToJsonConverter.ts | 4 +- .../conversion/ErrorToQuadConverter.ts | 4 +- .../conversion/ErrorToTemplateConverter.ts | 4 +- src/storage/conversion/FormToJsonConverter.ts | 4 +- .../conversion/MarkdownToHtmlConverter.ts | 4 +- src/storage/conversion/QuadToRdfConverter.ts | 4 +- src/storage/conversion/RdfToQuadConverter.ts | 4 +- .../TypedRepresentationConverter.ts | 63 +-------------- test/integration/GuardedStream.test.ts | 4 +- ... BaseTypedRepresentationConverter.test.ts} | 6 +- .../conversion/ChainedConverter.test.ts | 4 +- 14 files changed, 102 insertions(+), 84 deletions(-) create mode 100644 src/storage/conversion/BaseTypedRepresentationConverter.ts rename test/unit/storage/conversion/{TypedRepresentationConverter.test.ts => BaseTypedRepresentationConverter.test.ts} (92%) diff --git a/src/index.ts b/src/index.ts index 9b2617990..1fe5047ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -250,6 +250,7 @@ export * from './storage/accessors/InMemoryDataAccessor'; export * from './storage/accessors/SparqlDataAccessor'; // Storage/Conversion +export * from './storage/conversion/BaseTypedRepresentationConverter'; export * from './storage/conversion/ChainedConverter'; export * from './storage/conversion/ConstantConverter'; export * from './storage/conversion/ContainerToTemplateConverter'; diff --git a/src/storage/conversion/BaseTypedRepresentationConverter.ts b/src/storage/conversion/BaseTypedRepresentationConverter.ts new file mode 100644 index 000000000..3fc2a769f --- /dev/null +++ b/src/storage/conversion/BaseTypedRepresentationConverter.ts @@ -0,0 +1,76 @@ +import type { ValuePreferences } from '../../http/representation/RepresentationPreferences'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { getConversionTarget, getTypeWeight } from './ConversionUtil'; +import { RepresentationConverter } from './RepresentationConverter'; +import type { RepresentationConverterArgs } from './RepresentationConverter'; + +type PromiseOrValue = T | Promise; +type ValuePreferencesArg = + PromiseOrValue | + PromiseOrValue | + PromiseOrValue; + +async function toValuePreferences(arg: ValuePreferencesArg): Promise { + const resolved = await arg; + if (typeof resolved === 'string') { + return { [resolved]: 1 }; + } + if (Array.isArray(resolved)) { + return Object.fromEntries(resolved.map((type): [string, number] => [ type, 1 ])); + } + return resolved; +} + +/** + * A {@link RepresentationConverter} that allows requesting the supported types. + */ +export abstract class BaseTypedRepresentationConverter extends RepresentationConverter { + protected inputTypes: Promise; + protected outputTypes: Promise; + + public constructor(inputTypes: ValuePreferencesArg = {}, outputTypes: ValuePreferencesArg = {}) { + super(); + this.inputTypes = toValuePreferences(inputTypes); + this.outputTypes = toValuePreferences(outputTypes); + } + + /** + * Gets the supported input content types for this converter, mapped to a numerical priority. + */ + public async getInputTypes(): Promise { + return this.inputTypes; + } + + /** + * Gets the supported output content types for this converter, mapped to a numerical quality. + */ + public async getOutputTypes(): Promise { + return this.outputTypes; + } + + /** + * Determines whether the given conversion request is supported, + * given the available content type conversions: + * - Checks if there is a content type for the input. + * - Checks if the input type is supported by the parser. + * - Checks if the parser can produce one of the preferred output types. + * Throws an error with details if conversion is not possible. + */ + public async canHandle(args: RepresentationConverterArgs): Promise { + const types = [ this.getInputTypes(), this.getOutputTypes() ]; + const { contentType } = args.representation.metadata; + + if (!contentType) { + throw new NotImplementedHttpError('Can not convert data without a Content-Type.'); + } + + const [ inputTypes, outputTypes ] = await Promise.all(types); + const outputPreferences = args.preferences.type ?? {}; + if (getTypeWeight(contentType, inputTypes) === 0 || !getConversionTarget(outputTypes, outputPreferences)) { + throw new NotImplementedHttpError( + `Cannot convert from ${contentType} to ${Object.keys(outputPreferences) + }, only from ${Object.keys(inputTypes)} to ${Object.keys(outputTypes)}.`, + ); + } + } +} diff --git a/src/storage/conversion/ContainerToTemplateConverter.ts b/src/storage/conversion/ContainerToTemplateConverter.ts index d4e0f01e7..8dbafeaf1 100644 --- a/src/storage/conversion/ContainerToTemplateConverter.ts +++ b/src/storage/conversion/ContainerToTemplateConverter.ts @@ -11,8 +11,8 @@ import { isContainerIdentifier, isContainerPath } from '../../util/PathUtil'; import { endOfStream } from '../../util/StreamUtil'; import type { TemplateEngine } from '../../util/templates/TemplateEngine'; import { LDP } from '../../util/Vocabularies'; +import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { TypedRepresentationConverter } from './TypedRepresentationConverter'; interface ResourceDetails { name: string; @@ -23,7 +23,7 @@ interface ResourceDetails { /** * A {@link RepresentationConverter} that creates a templated representation of a container. */ -export class ContainerToTemplateConverter extends TypedRepresentationConverter { +export class ContainerToTemplateConverter extends BaseTypedRepresentationConverter { private readonly identifierStrategy: IdentifierStrategy; private readonly templateEngine: TemplateEngine; private readonly contentType: string; diff --git a/src/storage/conversion/ErrorToJsonConverter.ts b/src/storage/conversion/ErrorToJsonConverter.ts index a2f7c9c7f..2f33c44f8 100644 --- a/src/storage/conversion/ErrorToJsonConverter.ts +++ b/src/storage/conversion/ErrorToJsonConverter.ts @@ -3,13 +3,13 @@ import type { Representation } from '../../http/representation/Representation'; import { APPLICATION_JSON, INTERNAL_ERROR } from '../../util/ContentTypes'; import { HttpError } from '../../util/errors/HttpError'; import { getSingleItem } from '../../util/StreamUtil'; +import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * Converts an Error object to JSON by copying its fields. */ -export class ErrorToJsonConverter extends TypedRepresentationConverter { +export class ErrorToJsonConverter extends BaseTypedRepresentationConverter { public constructor() { super(INTERNAL_ERROR, APPLICATION_JSON); } diff --git a/src/storage/conversion/ErrorToQuadConverter.ts b/src/storage/conversion/ErrorToQuadConverter.ts index aace74b99..5ebdfb789 100644 --- a/src/storage/conversion/ErrorToQuadConverter.ts +++ b/src/storage/conversion/ErrorToQuadConverter.ts @@ -4,13 +4,13 @@ import { RepresentationMetadata } from '../../http/representation/Representation import { INTERNAL_ERROR, INTERNAL_QUADS } from '../../util/ContentTypes'; import { getSingleItem } from '../../util/StreamUtil'; import { DC, SOLID_ERROR } from '../../util/Vocabularies'; +import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * Converts an error object into quads by creating a triple for each of name/message/stack. */ -export class ErrorToQuadConverter extends TypedRepresentationConverter { +export class ErrorToQuadConverter extends BaseTypedRepresentationConverter { public constructor() { super(INTERNAL_ERROR, INTERNAL_QUADS); } diff --git a/src/storage/conversion/ErrorToTemplateConverter.ts b/src/storage/conversion/ErrorToTemplateConverter.ts index a72ec4456..923226fa2 100644 --- a/src/storage/conversion/ErrorToTemplateConverter.ts +++ b/src/storage/conversion/ErrorToTemplateConverter.ts @@ -6,8 +6,8 @@ import { HttpError } from '../../util/errors/HttpError'; import { modulePathPlaceholder } from '../../util/PathUtil'; import { getSingleItem } from '../../util/StreamUtil'; import type { TemplateEngine } from '../../util/templates/TemplateEngine'; +import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { TypedRepresentationConverter } from './TypedRepresentationConverter'; // Fields optional due to https://github.com/LinkedSoftwareDependencies/Components.js/issues/20 export interface TemplateOptions { @@ -35,7 +35,7 @@ const DEFAULT_TEMPLATE_OPTIONS: TemplateOptions = { * That result will be passed as an additional parameter to the main templating call, * using the variable `codeMessage`. */ -export class ErrorToTemplateConverter extends TypedRepresentationConverter { +export class ErrorToTemplateConverter extends BaseTypedRepresentationConverter { private readonly templateEngine: TemplateEngine; private readonly mainTemplatePath: string; private readonly codeTemplatesPath: string; diff --git a/src/storage/conversion/FormToJsonConverter.ts b/src/storage/conversion/FormToJsonConverter.ts index a964d5cc9..48e8c8f4b 100644 --- a/src/storage/conversion/FormToJsonConverter.ts +++ b/src/storage/conversion/FormToJsonConverter.ts @@ -5,14 +5,14 @@ import { RepresentationMetadata } from '../../http/representation/Representation import { APPLICATION_JSON, APPLICATION_X_WWW_FORM_URLENCODED } from '../../util/ContentTypes'; import { readableToString } from '../../util/StreamUtil'; import { CONTENT_TYPE } from '../../util/Vocabularies'; +import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * Converts application/x-www-form-urlencoded data to application/json. * Due to the nature of form data, the result will be a simple key/value JSON object. */ -export class FormToJsonConverter extends TypedRepresentationConverter { +export class FormToJsonConverter extends BaseTypedRepresentationConverter { public constructor() { super(APPLICATION_X_WWW_FORM_URLENCODED, APPLICATION_JSON); } diff --git a/src/storage/conversion/MarkdownToHtmlConverter.ts b/src/storage/conversion/MarkdownToHtmlConverter.ts index 1bbae4d2c..754bc5248 100644 --- a/src/storage/conversion/MarkdownToHtmlConverter.ts +++ b/src/storage/conversion/MarkdownToHtmlConverter.ts @@ -4,8 +4,8 @@ import type { Representation } from '../../http/representation/Representation'; import { TEXT_HTML, TEXT_MARKDOWN } from '../../util/ContentTypes'; import { readableToString } from '../../util/StreamUtil'; import type { TemplateEngine } from '../../util/templates/TemplateEngine'; +import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * Converts Markdown data to HTML. @@ -13,7 +13,7 @@ import { TypedRepresentationConverter } from './TypedRepresentationConverter'; * A standard Markdown string will be converted to a

tag, so html and body tags should be part of the template. * In case the Markdown body starts with a header (#), that value will also be used as `title` parameter. */ -export class MarkdownToHtmlConverter extends TypedRepresentationConverter { +export class MarkdownToHtmlConverter extends BaseTypedRepresentationConverter { private readonly templateEngine: TemplateEngine; public constructor(templateEngine: TemplateEngine) { diff --git a/src/storage/conversion/QuadToRdfConverter.ts b/src/storage/conversion/QuadToRdfConverter.ts index 512832b44..e343f3b67 100644 --- a/src/storage/conversion/QuadToRdfConverter.ts +++ b/src/storage/conversion/QuadToRdfConverter.ts @@ -7,14 +7,14 @@ import type { ValuePreferences } from '../../http/representation/RepresentationP import { INTERNAL_QUADS } from '../../util/ContentTypes'; import { pipeSafely } from '../../util/StreamUtil'; import { PREFERRED_PREFIX_TERM } from '../../util/Vocabularies'; +import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter'; import { getConversionTarget } from './ConversionUtil'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * Converts `internal/quads` to most major RDF serializations. */ -export class QuadToRdfConverter extends TypedRepresentationConverter { +export class QuadToRdfConverter extends BaseTypedRepresentationConverter { private readonly outputPreferences?: ValuePreferences; public constructor(options: { outputPreferences?: Record } = {}) { diff --git a/src/storage/conversion/RdfToQuadConverter.ts b/src/storage/conversion/RdfToQuadConverter.ts index 521cd4865..19295bd96 100644 --- a/src/storage/conversion/RdfToQuadConverter.ts +++ b/src/storage/conversion/RdfToQuadConverter.ts @@ -5,13 +5,13 @@ import type { Representation } from '../../http/representation/Representation'; import { INTERNAL_QUADS } from '../../util/ContentTypes'; import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; import { pipeSafely } from '../../util/StreamUtil'; +import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * Converts most major RDF serializations to `internal/quads`. */ -export class RdfToQuadConverter extends TypedRepresentationConverter { +export class RdfToQuadConverter extends BaseTypedRepresentationConverter { public constructor() { super(rdfParser.getContentTypesPrioritized(), INTERNAL_QUADS); } diff --git a/src/storage/conversion/TypedRepresentationConverter.ts b/src/storage/conversion/TypedRepresentationConverter.ts index 75eb7e00f..60e41b31d 100644 --- a/src/storage/conversion/TypedRepresentationConverter.ts +++ b/src/storage/conversion/TypedRepresentationConverter.ts @@ -1,76 +1,17 @@ import type { ValuePreferences } from '../../http/representation/RepresentationPreferences'; -import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; -import { getConversionTarget, getTypeWeight } from './ConversionUtil'; import { RepresentationConverter } from './RepresentationConverter'; -import type { RepresentationConverterArgs } from './RepresentationConverter'; - -type PromiseOrValue = T | Promise; -type ValuePreferencesArg = - PromiseOrValue | - PromiseOrValue | - PromiseOrValue; - -async function toValuePreferences(arg: ValuePreferencesArg): Promise { - const resolved = await arg; - if (typeof resolved === 'string') { - return { [resolved]: 1 }; - } - if (Array.isArray(resolved)) { - return Object.fromEntries(resolved.map((type): [string, number] => [ type, 1 ])); - } - return resolved; -} /** * A {@link RepresentationConverter} that allows requesting the supported types. */ export abstract class TypedRepresentationConverter extends RepresentationConverter { - protected inputTypes: Promise; - protected outputTypes: Promise; - - public constructor(inputTypes: ValuePreferencesArg = {}, outputTypes: ValuePreferencesArg = {}) { - super(); - this.inputTypes = toValuePreferences(inputTypes); - this.outputTypes = toValuePreferences(outputTypes); - } - /** * Gets the supported input content types for this converter, mapped to a numerical priority. */ - public async getInputTypes(): Promise { - return this.inputTypes; - } + public abstract getInputTypes(): Promise; /** * Gets the supported output content types for this converter, mapped to a numerical quality. */ - public async getOutputTypes(): Promise { - return this.outputTypes; - } - - /** - * Determines whether the given conversion request is supported, - * given the available content type conversions: - * - Checks if there is a content type for the input. - * - Checks if the input type is supported by the parser. - * - Checks if the parser can produce one of the preferred output types. - * Throws an error with details if conversion is not possible. - */ - public async canHandle(args: RepresentationConverterArgs): Promise { - const types = [ this.getInputTypes(), this.getOutputTypes() ]; - const { contentType } = args.representation.metadata; - - if (!contentType) { - throw new NotImplementedHttpError('Can not convert data without a Content-Type.'); - } - - const [ inputTypes, outputTypes ] = await Promise.all(types); - const outputPreferences = args.preferences.type ?? {}; - if (getTypeWeight(contentType, inputTypes) === 0 || !getConversionTarget(outputTypes, outputPreferences)) { - throw new NotImplementedHttpError( - `Cannot convert from ${contentType} to ${Object.keys(outputPreferences) - }, only from ${Object.keys(inputTypes)} to ${Object.keys(outputTypes)}.`, - ); - } - } + public abstract getOutputTypes(): Promise; } diff --git a/test/integration/GuardedStream.test.ts b/test/integration/GuardedStream.test.ts index d1f478de7..1cadb4906 100644 --- a/test/integration/GuardedStream.test.ts +++ b/test/integration/GuardedStream.test.ts @@ -1,6 +1,5 @@ import { RepresentationMetadata, - TypedRepresentationConverter, readableToString, ChainedConverter, guardedStreamFrom, @@ -12,6 +11,7 @@ import { import type { Representation, RepresentationConverterArgs, Logger } from '../../src'; +import { BaseTypedRepresentationConverter } from '../../src/storage/conversion/BaseTypedRepresentationConverter'; jest.mock('../../src/logging/LogUtil', (): any => { const logger: Logger = @@ -20,7 +20,7 @@ jest.mock('../../src/logging/LogUtil', (): any => { }); const logger: jest.Mocked = getLoggerFor('GuardedStream') as any; -class DummyConverter extends TypedRepresentationConverter { +class DummyConverter extends BaseTypedRepresentationConverter { public constructor() { super('*/*', 'custom/type'); } diff --git a/test/unit/storage/conversion/TypedRepresentationConverter.test.ts b/test/unit/storage/conversion/BaseTypedRepresentationConverter.test.ts similarity index 92% rename from test/unit/storage/conversion/TypedRepresentationConverter.test.ts rename to test/unit/storage/conversion/BaseTypedRepresentationConverter.test.ts index f9d5561a3..dc82d1664 100644 --- a/test/unit/storage/conversion/TypedRepresentationConverter.test.ts +++ b/test/unit/storage/conversion/BaseTypedRepresentationConverter.test.ts @@ -1,12 +1,12 @@ +import { BaseTypedRepresentationConverter } from '../../../../src/storage/conversion/BaseTypedRepresentationConverter'; import type { RepresentationConverterArgs } from '../../../../src/storage/conversion/RepresentationConverter'; -import { TypedRepresentationConverter } from '../../../../src/storage/conversion/TypedRepresentationConverter'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; -class CustomTypedRepresentationConverter extends TypedRepresentationConverter { +class CustomTypedRepresentationConverter extends BaseTypedRepresentationConverter { public handle = jest.fn(); } -describe('A TypedRepresentationConverter', (): void => { +describe('A BaseTypedRepresentationConverter', (): void => { it('defaults to allowing everything.', async(): Promise => { const converter = new CustomTypedRepresentationConverter(); await expect(converter.getInputTypes()).resolves.toEqual({ diff --git a/test/unit/storage/conversion/ChainedConverter.test.ts b/test/unit/storage/conversion/ChainedConverter.test.ts index fa7d35c04..8c98cf990 100644 --- a/test/unit/storage/conversion/ChainedConverter.test.ts +++ b/test/unit/storage/conversion/ChainedConverter.test.ts @@ -4,13 +4,13 @@ import type { RepresentationPreferences, ValuePreferences, } from '../../../../src/http/representation/RepresentationPreferences'; +import { BaseTypedRepresentationConverter } from '../../../../src/storage/conversion/BaseTypedRepresentationConverter'; import { ChainedConverter } from '../../../../src/storage/conversion/ChainedConverter'; import { matchesMediaType } from '../../../../src/storage/conversion/ConversionUtil'; import type { RepresentationConverterArgs } from '../../../../src/storage/conversion/RepresentationConverter'; -import { TypedRepresentationConverter } from '../../../../src/storage/conversion/TypedRepresentationConverter'; import { CONTENT_TYPE } from '../../../../src/util/Vocabularies'; -class DummyConverter extends TypedRepresentationConverter { +class DummyConverter extends BaseTypedRepresentationConverter { private readonly inTypes: ValuePreferences; private readonly outTypes: ValuePreferences; From fa94c7d4bb0d67b0cde264f9515260293b3b904a Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 26 Oct 2021 16:30:10 +0200 Subject: [PATCH 02/39] feat: Determine Typed Converter output based on input type --- .../BaseTypedRepresentationConverter.ts | 46 ++++++------ src/storage/conversion/ChainedConverter.ts | 72 +++++++++---------- src/storage/conversion/ConversionUtil.ts | 10 +++ .../conversion/ErrorToTemplateConverter.ts | 2 +- src/storage/conversion/QuadToRdfConverter.ts | 4 +- .../TypedRepresentationConverter.ts | 9 +-- test/integration/GuardedStream.test.ts | 10 +-- .../BaseTypedRepresentationConverter.test.ts | 35 +++------ .../conversion/ChainedConverter.test.ts | 10 +-- .../storage/conversion/ConversionUtil.test.ts | 9 ++- .../conversion/ErrorToJsonConverter.test.ts | 3 +- .../conversion/ErrorToQuadConverter.test.ts | 3 +- .../ErrorToTemplateConverter.test.ts | 3 +- .../conversion/FormToJsonConverter.test.ts | 4 +- .../MarkdownToHtmlConverter.test.ts | 3 +- .../conversion/QuadToRdfConverter.test.ts | 9 +-- .../conversion/RdfToQuadConverter.test.ts | 9 ++- 17 files changed, 107 insertions(+), 134 deletions(-) diff --git a/src/storage/conversion/BaseTypedRepresentationConverter.ts b/src/storage/conversion/BaseTypedRepresentationConverter.ts index 3fc2a769f..e5e3c40b6 100644 --- a/src/storage/conversion/BaseTypedRepresentationConverter.ts +++ b/src/storage/conversion/BaseTypedRepresentationConverter.ts @@ -1,8 +1,8 @@ import type { ValuePreferences } from '../../http/representation/RepresentationPreferences'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; -import { getConversionTarget, getTypeWeight } from './ConversionUtil'; -import { RepresentationConverter } from './RepresentationConverter'; +import { getConversionTarget, getTypeWeight, preferencesToString } from './ConversionUtil'; import type { RepresentationConverterArgs } from './RepresentationConverter'; +import { TypedRepresentationConverter } from './TypedRepresentationConverter'; type PromiseOrValue = T | Promise; type ValuePreferencesArg = @@ -22,30 +22,37 @@ async function toValuePreferences(arg: ValuePreferencesArg): Promise; protected outputTypes: Promise; - public constructor(inputTypes: ValuePreferencesArg = {}, outputTypes: ValuePreferencesArg = {}) { + public constructor(inputTypes: ValuePreferencesArg, outputTypes: ValuePreferencesArg) { super(); this.inputTypes = toValuePreferences(inputTypes); this.outputTypes = toValuePreferences(outputTypes); } /** - * Gets the supported input content types for this converter, mapped to a numerical priority. + * Matches all inputs to all outputs. */ - public async getInputTypes(): Promise { - return this.inputTypes; - } - - /** - * Gets the supported output content types for this converter, mapped to a numerical quality. - */ - public async getOutputTypes(): Promise { - return this.outputTypes; + public async getOutputTypes(contentType: string): Promise { + const weight = getTypeWeight(contentType, await this.inputTypes); + if (weight > 0) { + const outputTypes = { ...await this.outputTypes }; + for (const [ key, value ] of Object.entries(outputTypes)) { + outputTypes[key] = value * weight; + } + return outputTypes; + } + return {}; } /** @@ -57,19 +64,18 @@ export abstract class BaseTypedRepresentationConverter extends RepresentationCon * Throws an error with details if conversion is not possible. */ public async canHandle(args: RepresentationConverterArgs): Promise { - const types = [ this.getInputTypes(), this.getOutputTypes() ]; const { contentType } = args.representation.metadata; if (!contentType) { throw new NotImplementedHttpError('Can not convert data without a Content-Type.'); } - const [ inputTypes, outputTypes ] = await Promise.all(types); + const outputTypes = await this.getOutputTypes(contentType); const outputPreferences = args.preferences.type ?? {}; - if (getTypeWeight(contentType, inputTypes) === 0 || !getConversionTarget(outputTypes, outputPreferences)) { + if (!getConversionTarget(outputTypes, outputPreferences)) { throw new NotImplementedHttpError( - `Cannot convert from ${contentType} to ${Object.keys(outputPreferences) - }, only from ${Object.keys(inputTypes)} to ${Object.keys(outputTypes)}.`, + `Cannot convert from ${contentType} to ${preferencesToString(outputPreferences) + }, only to ${preferencesToString(outputTypes)}.`, ); } } diff --git a/src/storage/conversion/ChainedConverter.ts b/src/storage/conversion/ChainedConverter.ts index f1c765d24..569727412 100644 --- a/src/storage/conversion/ChainedConverter.ts +++ b/src/storage/conversion/ChainedConverter.ts @@ -3,12 +3,16 @@ import type { ValuePreference, ValuePreferences } from '../../http/representatio import { getLoggerFor } from '../../logging/LogUtil'; import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; -import { cleanPreferences, getBestPreference, getTypeWeight } from './ConversionUtil'; +import { cleanPreferences, getBestPreference, getTypeWeight, preferencesToString } from './ConversionUtil'; import type { RepresentationConverterArgs } from './RepresentationConverter'; import { RepresentationConverter } from './RepresentationConverter'; import type { TypedRepresentationConverter } from './TypedRepresentationConverter'; -type ConverterPreference = ValuePreference & { converter: TypedRepresentationConverter }; +type ConverterPreference = { + converter: TypedRepresentationConverter; + inType: string; + outTypes: ValuePreferences; +}; /** * A chain of converters that can go from `inTypes` to `outTypes`. @@ -17,16 +21,15 @@ type ConverterPreference = ValuePreference & { converter: TypedRepresentationCon type ConversionPath = { converters: TypedRepresentationConverter[]; intermediateTypes: string[]; - inTypes: ValuePreferences; + inType: string; outTypes: ValuePreferences; }; /** - * The result of applying a `ConversionPath` to a specific input. + * The result of choosing a specific output for a `ConversionPath`. */ type MatchedPath = { path: ConversionPath; - inType: string; outType: string; weight: number; }; @@ -78,8 +81,8 @@ export class ChainedConverter extends RepresentationConverter { return input.representation; } - const { path, inType, outType } = match; - this.logger.debug(`Converting ${inType} -> ${[ ...path.intermediateTypes, outType ].join(' -> ')}.`); + const { path, outType } = match; + this.logger.debug(`Converting ${path.inType} -> ${[ ...path.intermediateTypes, outType ].join(' -> ')}.`); const args = { ...input }; for (let i = 0; i < path.converters.length - 1; ++i) { @@ -105,7 +108,7 @@ export class ChainedConverter extends RepresentationConverter { const weight = getTypeWeight(type, preferences); if (weight > 0) { - this.logger.debug(`No conversion required: ${type} already matches ${Object.keys(preferences)}`); + this.logger.debug(`No conversion required: ${type} already matches ${preferencesToString(preferences)}`); return { value: type, weight }; } @@ -122,13 +125,13 @@ export class ChainedConverter extends RepresentationConverter { // Generate paths from all converters that match the input type let paths = await this.converters.reduce(async(matches: Promise, converter): Promise => { - const inTypes = await converter.getInputTypes(); - if (getTypeWeight(inType, inTypes) > 0) { + const outTypes = await converter.getOutputTypes(inType); + if (Object.keys(outTypes).length > 0) { (await matches).push({ converters: [ converter ], intermediateTypes: [], - inTypes, - outTypes: await converter.getOutputTypes(), + inType, + outTypes, }); } return matches; @@ -137,18 +140,18 @@ export class ChainedConverter extends RepresentationConverter { // It's impossible for a path to have a higher weight than this value const maxWeight = Math.max(...Object.values(outPreferences)); - let bestPath = this.findBest(inType, outPreferences, paths); - paths = this.removeBadPaths(paths, maxWeight, inType, bestPath); + let bestPath = this.findBest(outPreferences, paths); + paths = this.removeBadPaths(paths, maxWeight, bestPath); // This will always stop at some point since paths can't have the same converter twice while (paths.length > 0) { // For every path, find all the paths that can be made by adding 1 more converter const promises = paths.map(async(path): Promise => this.takeStep(path)); paths = (await Promise.all(promises)).flat(); - const newBest = this.findBest(inType, outPreferences, paths); + const newBest = this.findBest(outPreferences, paths); if (newBest && (!bestPath || newBest.weight > bestPath.weight)) { bestPath = newBest; } - paths = this.removeBadPaths(paths, maxWeight, inType, bestPath); + paths = this.removeBadPaths(paths, maxWeight, bestPath); } if (!bestPath) { @@ -161,18 +164,17 @@ export class ChainedConverter extends RepresentationConverter { } /** - * Finds the path from the given list that can convert the given type to the given preferences. + * Finds the path from the given list that can convert to the given preferences. * If there are multiple matches the one with the highest result weight gets chosen. * Will return undefined if there are no matches. */ - private findBest(type: string, preferences: ValuePreferences, paths: ConversionPath[]): MatchedPath | undefined { + private findBest(preferences: ValuePreferences, paths: ConversionPath[]): MatchedPath | undefined { // Need to use null instead of undefined so `reduce` doesn't take the first element of the array as `best` return paths.reduce((best: MatchedPath | null, path): MatchedPath | null => { const outMatch = getBestPreference(path.outTypes, preferences); if (outMatch && !(best && best.weight >= outMatch.weight)) { // Create new MatchedPath, using the output match above - const inWeight = getTypeWeight(type, path.inTypes); - return { path, inType: type, outType: outMatch.value, weight: inWeight * outMatch.weight }; + return { path, outType: outMatch.value, weight: outMatch.weight }; } return best; }, null) ?? undefined; @@ -184,11 +186,9 @@ export class ChainedConverter extends RepresentationConverter { * * @param paths - Paths to filter. * @param maxWeight - The maximum weight in the output preferences. - * @param inType - The input type. * @param bestMatch - The current best path. */ - private removeBadPaths(paths: ConversionPath[], maxWeight: number, inType: string, bestMatch?: MatchedPath): - ConversionPath[] { + private removeBadPaths(paths: ConversionPath[], maxWeight: number, bestMatch?: MatchedPath): ConversionPath[] { // All paths are still good if there is no best match yet if (!bestMatch) { return paths; @@ -200,9 +200,7 @@ export class ChainedConverter extends RepresentationConverter { // Only return paths that can potentially improve upon bestPath return paths.filter((path): boolean => { - const optimisticWeight = getTypeWeight(inType, path.inTypes) * - Math.max(...Object.values(path.outTypes)) * - maxWeight; + const optimisticWeight = Math.max(...Object.values(path.outTypes)) * maxWeight; return optimisticWeight > bestMatch.weight; }); } @@ -218,9 +216,9 @@ export class ChainedConverter extends RepresentationConverter { // Create a new path for every converter that can be appended return Promise.all(nextConverters.map(async(pref): Promise => ({ converters: [ ...path.converters, pref.converter ], - intermediateTypes: [ ...path.intermediateTypes, pref.value ], - inTypes: path.inTypes, - outTypes: this.modifyTypeWeights(pref.weight, await pref.converter.getOutputTypes()), + intermediateTypes: [ ...path.intermediateTypes, pref.inType ], + inType: path.inType, + outTypes: pref.outTypes, }))); } @@ -237,13 +235,15 @@ export class ChainedConverter extends RepresentationConverter { */ private async supportedConverters(types: ValuePreferences, converters: TypedRepresentationConverter[]): Promise { - const promises = converters.map(async(converter): Promise => { - const inputTypes = await converter.getInputTypes(); - const match = getBestPreference(types, inputTypes); - if (match) { - return { ...match, converter }; + const typeEntries = Object.entries(types); + const results: ConverterPreference[] = []; + for (const converter of converters) { + for (const [ inType, weight ] of typeEntries) { + let outTypes = await converter.getOutputTypes(inType); + outTypes = this.modifyTypeWeights(weight, outTypes); + results.push({ converter, inType, outTypes }); } - }); - return (await Promise.all(promises)).filter(Boolean) as ConverterPreference[]; + } + return results; } } diff --git a/src/storage/conversion/ConversionUtil.ts b/src/storage/conversion/ConversionUtil.ts index 1ed97dc9f..c237061fe 100644 --- a/src/storage/conversion/ConversionUtil.ts +++ b/src/storage/conversion/ConversionUtil.ts @@ -164,3 +164,13 @@ export function matchesMediaType(mediaA: string, mediaB: string): boolean { export function isInternalContentType(contentType?: string): boolean { return typeof contentType !== 'undefined' && matchesMediaType(contentType, INTERNAL_ALL); } + +/** + * Serializes a preferences object to a string for display purposes. + * @param preferences - Preferences to serialize + */ +export function preferencesToString(preferences: ValuePreferences): string { + return Object.entries(preferences) + .map(([ type, weight ]): string => `${type}:${weight}`) + .join(','); +} diff --git a/src/storage/conversion/ErrorToTemplateConverter.ts b/src/storage/conversion/ErrorToTemplateConverter.ts index 923226fa2..563668a54 100644 --- a/src/storage/conversion/ErrorToTemplateConverter.ts +++ b/src/storage/conversion/ErrorToTemplateConverter.ts @@ -43,7 +43,7 @@ export class ErrorToTemplateConverter extends BaseTypedRepresentationConverter { private readonly contentType: string; public constructor(templateEngine: TemplateEngine, templateOptions?: TemplateOptions) { - super(INTERNAL_ERROR, templateOptions?.contentType ?? DEFAULT_TEMPLATE_OPTIONS.contentType); + super(INTERNAL_ERROR, templateOptions?.contentType ?? DEFAULT_TEMPLATE_OPTIONS.contentType!); // Workaround for https://github.com/LinkedSoftwareDependencies/Components.js/issues/20 if (!templateOptions || Object.keys(templateOptions).length === 0) { templateOptions = DEFAULT_TEMPLATE_OPTIONS; diff --git a/src/storage/conversion/QuadToRdfConverter.ts b/src/storage/conversion/QuadToRdfConverter.ts index e343f3b67..6432c6555 100644 --- a/src/storage/conversion/QuadToRdfConverter.ts +++ b/src/storage/conversion/QuadToRdfConverter.ts @@ -27,7 +27,7 @@ export class QuadToRdfConverter extends BaseTypedRepresentationConverter { public async handle({ identifier, representation: quads, preferences }: RepresentationConverterArgs): Promise { // Can not be undefined if the `canHandle` call passed - const contentType = getConversionTarget(await this.getOutputTypes(), preferences.type)!; + const contentType = getConversionTarget(await this.getOutputTypes(INTERNAL_QUADS), preferences.type)!; let data: Readable; // Use prefixes if possible (see https://github.com/rubensworks/rdf-serialize.js/issues/1) @@ -36,7 +36,7 @@ export class QuadToRdfConverter extends BaseTypedRepresentationConverter { .map(({ subject, object }): [string, string] => [ object.value, subject.value ])); const options = { format: contentType, baseIRI: identifier.path, prefixes }; data = pipeSafely(quads.data, new StreamWriter(options)); - // Otherwise, write without prefixes + // Otherwise, write without prefixes } else { data = rdfSerializer.serialize(quads.data, { contentType }) as Readable; } diff --git a/src/storage/conversion/TypedRepresentationConverter.ts b/src/storage/conversion/TypedRepresentationConverter.ts index 60e41b31d..295664c9c 100644 --- a/src/storage/conversion/TypedRepresentationConverter.ts +++ b/src/storage/conversion/TypedRepresentationConverter.ts @@ -6,12 +6,7 @@ import { RepresentationConverter } from './RepresentationConverter'; */ export abstract class TypedRepresentationConverter extends RepresentationConverter { /** - * Gets the supported input content types for this converter, mapped to a numerical priority. + * Gets the output content types this converter can convert the input type to, mapped to a numerical priority. */ - public abstract getInputTypes(): Promise; - - /** - * Gets the supported output content types for this converter, mapped to a numerical quality. - */ - public abstract getOutputTypes(): Promise; + public abstract getOutputTypes(contentType: string): Promise; } diff --git a/test/integration/GuardedStream.test.ts b/test/integration/GuardedStream.test.ts index 1cadb4906..4963da1d5 100644 --- a/test/integration/GuardedStream.test.ts +++ b/test/integration/GuardedStream.test.ts @@ -22,15 +22,7 @@ const logger: jest.Mocked = getLoggerFor('GuardedStream') as any; class DummyConverter extends BaseTypedRepresentationConverter { public constructor() { - super('*/*', 'custom/type'); - } - - public async getInputTypes(): Promise> { - return { [INTERNAL_QUADS]: 1 }; - } - - public async getOutputTypes(): Promise> { - return { 'x/x': 1 }; + super(INTERNAL_QUADS, 'x/x'); } public async handle({ representation }: RepresentationConverterArgs): Promise { diff --git a/test/unit/storage/conversion/BaseTypedRepresentationConverter.test.ts b/test/unit/storage/conversion/BaseTypedRepresentationConverter.test.ts index dc82d1664..3f25a68de 100644 --- a/test/unit/storage/conversion/BaseTypedRepresentationConverter.test.ts +++ b/test/unit/storage/conversion/BaseTypedRepresentationConverter.test.ts @@ -7,56 +7,37 @@ class CustomTypedRepresentationConverter extends BaseTypedRepresentationConverte } describe('A BaseTypedRepresentationConverter', (): void => { - it('defaults to allowing everything.', async(): Promise => { - const converter = new CustomTypedRepresentationConverter(); - await expect(converter.getInputTypes()).resolves.toEqual({ - }); - await expect(converter.getOutputTypes()).resolves.toEqual({ - }); - }); - it('accepts strings.', async(): Promise => { const converter = new CustomTypedRepresentationConverter('a/b', 'c/d'); - await expect(converter.getInputTypes()).resolves.toEqual({ - 'a/b': 1, - }); - await expect(converter.getOutputTypes()).resolves.toEqual({ + await expect(converter.getOutputTypes('a/b')).resolves.toEqual({ 'c/d': 1, }); }); it('accepts string arrays.', async(): Promise => { const converter = new CustomTypedRepresentationConverter([ 'a/b', 'c/d' ], [ 'e/f', 'g/h' ]); - await expect(converter.getInputTypes()).resolves.toEqual({ - 'a/b': 1, - 'c/d': 1, - }); - await expect(converter.getOutputTypes()).resolves.toEqual({ - 'e/f': 1, - 'g/h': 1, - }); + const output = { 'e/f': 1, 'g/h': 1 }; + await expect(converter.getOutputTypes('a/b')).resolves.toEqual(output); + await expect(converter.getOutputTypes('c/d')).resolves.toEqual(output); }); it('accepts records.', async(): Promise => { const converter = new CustomTypedRepresentationConverter({ 'a/b': 0.5 }, { 'c/d': 0.5 }); - await expect(converter.getInputTypes()).resolves.toEqual({ - 'a/b': 0.5, - }); - await expect(converter.getOutputTypes()).resolves.toEqual({ - 'c/d': 0.5, + await expect(converter.getOutputTypes('a/b')).resolves.toEqual({ + 'c/d': 0.5 * 0.5, }); }); it('can not handle input without a Content-Type.', async(): Promise => { const args: RepresentationConverterArgs = { representation: { metadata: { }}, preferences: {}} as any; - const converter = new CustomTypedRepresentationConverter('*/*'); + const converter = new CustomTypedRepresentationConverter('*/*', 'b/b'); await expect(converter.canHandle(args)).rejects.toThrow(NotImplementedHttpError); }); it('can not handle a type that does not match the input types.', async(): Promise => { const args: RepresentationConverterArgs = { representation: { metadata: { contentType: 'b/b' }}, preferences: {}} as any; - const converter = new CustomTypedRepresentationConverter('a/a'); + const converter = new CustomTypedRepresentationConverter('a/a', 'b/b'); await expect(converter.canHandle(args)).rejects.toThrow(NotImplementedHttpError); }); diff --git a/test/unit/storage/conversion/ChainedConverter.test.ts b/test/unit/storage/conversion/ChainedConverter.test.ts index 8c98cf990..d75c1a212 100644 --- a/test/unit/storage/conversion/ChainedConverter.test.ts +++ b/test/unit/storage/conversion/ChainedConverter.test.ts @@ -15,19 +15,11 @@ class DummyConverter extends BaseTypedRepresentationConverter { private readonly outTypes: ValuePreferences; public constructor(inTypes: ValuePreferences, outTypes: ValuePreferences) { - super(); + super(inTypes, outTypes); this.inTypes = inTypes; this.outTypes = outTypes; } - public async getInputTypes(): Promise { - return this.inTypes; - } - - public async getOutputTypes(): Promise { - return this.outTypes; - } - public async handle(input: RepresentationConverterArgs): Promise { // Make sure the input type is supported const inType = input.representation.metadata.contentType!; diff --git a/test/unit/storage/conversion/ConversionUtil.test.ts b/test/unit/storage/conversion/ConversionUtil.test.ts index bba2123f5..42124518e 100644 --- a/test/unit/storage/conversion/ConversionUtil.test.ts +++ b/test/unit/storage/conversion/ConversionUtil.test.ts @@ -6,7 +6,7 @@ import { getTypeWeight, getWeightedPreferences, isInternalContentType, matchesMediaPreferences, - matchesMediaType, + matchesMediaType, preferencesToString, } from '../../../../src/storage/conversion/ConversionUtil'; import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; @@ -153,4 +153,11 @@ describe('ConversionUtil', (): void => { expect(isInternalContentType('text/turtle')).toBeFalsy(); }); }); + + describe('#preferencesToString', (): void => { + it('returns a string serialization.', async(): Promise => { + const preferences: ValuePreferences = { 'a/*': 1, 'b/b': 0.8, 'c/c': 0 }; + expect(preferencesToString(preferences)).toEqual('a/*:1,b/b:0.8,c/c:0'); + }); + }); }); diff --git a/test/unit/storage/conversion/ErrorToJsonConverter.test.ts b/test/unit/storage/conversion/ErrorToJsonConverter.test.ts index a7c0fc4f8..1b9ca1f01 100644 --- a/test/unit/storage/conversion/ErrorToJsonConverter.test.ts +++ b/test/unit/storage/conversion/ErrorToJsonConverter.test.ts @@ -9,8 +9,7 @@ describe('An ErrorToJsonConverter', (): void => { const preferences = {}; it('supports going from errors to json.', async(): Promise => { - await expect(converter.getInputTypes()).resolves.toEqual({ 'internal/error': 1 }); - await expect(converter.getOutputTypes()).resolves.toEqual({ 'application/json': 1 }); + await expect(converter.getOutputTypes('internal/error')).resolves.toEqual({ 'application/json': 1 }); }); it('adds all HttpError fields.', async(): Promise => { diff --git a/test/unit/storage/conversion/ErrorToQuadConverter.test.ts b/test/unit/storage/conversion/ErrorToQuadConverter.test.ts index 3def938e0..68736d7e8 100644 --- a/test/unit/storage/conversion/ErrorToQuadConverter.test.ts +++ b/test/unit/storage/conversion/ErrorToQuadConverter.test.ts @@ -13,8 +13,7 @@ describe('An ErrorToQuadConverter', (): void => { const preferences = {}; it('supports going from errors to quads.', async(): Promise => { - await expect(converter.getInputTypes()).resolves.toEqual({ 'internal/error': 1 }); - await expect(converter.getOutputTypes()).resolves.toEqual({ 'internal/quads': 1 }); + await expect(converter.getOutputTypes('internal/error')).resolves.toEqual({ 'internal/quads': 1 }); }); it('adds triples for all error fields.', async(): Promise => { diff --git a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts index ecc3fe861..27f106859 100644 --- a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts +++ b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts @@ -24,8 +24,7 @@ describe('An ErrorToTemplateConverter', (): void => { }); it('supports going from errors to the given content type.', async(): Promise => { - await expect(converter.getInputTypes()).resolves.toEqual({ 'internal/error': 1 }); - await expect(converter.getOutputTypes()).resolves.toEqual({ 'text/html': 1 }); + await expect(converter.getOutputTypes('internal/error')).resolves.toEqual({ 'text/html': 1 }); }); it('works with non-HTTP errors.', async(): Promise => { diff --git a/test/unit/storage/conversion/FormToJsonConverter.test.ts b/test/unit/storage/conversion/FormToJsonConverter.test.ts index 11fbb5e4b..4f7d89d35 100644 --- a/test/unit/storage/conversion/FormToJsonConverter.test.ts +++ b/test/unit/storage/conversion/FormToJsonConverter.test.ts @@ -8,8 +8,8 @@ describe('A FormToJsonConverter', (): void => { const converter = new FormToJsonConverter(); it('supports going from form data to json.', async(): Promise => { - await expect(converter.getInputTypes()).resolves.toEqual({ 'application/x-www-form-urlencoded': 1 }); - await expect(converter.getOutputTypes()).resolves.toEqual({ 'application/json': 1 }); + await expect(converter.getOutputTypes('application/x-www-form-urlencoded')) + .resolves.toEqual({ 'application/json': 1 }); }); it('converts form data to JSON.', async(): Promise => { diff --git a/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts b/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts index 733142b1f..7c3cb7c7c 100644 --- a/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts +++ b/test/unit/storage/conversion/MarkdownToHtmlConverter.test.ts @@ -17,8 +17,7 @@ describe('A MarkdownToHtmlConverter', (): void => { }); it('supports going from markdown to html.', async(): Promise => { - await expect(converter.getInputTypes()).resolves.toEqual({ 'text/markdown': 1 }); - await expect(converter.getOutputTypes()).resolves.toEqual({ 'text/html': 1 }); + await expect(converter.getOutputTypes('text/markdown')).resolves.toEqual({ 'text/html': 1 }); }); it('converts markdown and inserts it in the template.', async(): Promise => { diff --git a/test/unit/storage/conversion/QuadToRdfConverter.test.ts b/test/unit/storage/conversion/QuadToRdfConverter.test.ts index 6b83c0ccb..983b4a2be 100644 --- a/test/unit/storage/conversion/QuadToRdfConverter.test.ts +++ b/test/unit/storage/conversion/QuadToRdfConverter.test.ts @@ -19,19 +19,14 @@ describe('A QuadToRdfConverter', (): void => { metadata = new RepresentationMetadata(identifier, INTERNAL_QUADS); }); - it('supports parsing quads.', async(): Promise => { - await expect(new QuadToRdfConverter().getInputTypes()) - .resolves.toEqual({ [INTERNAL_QUADS]: 1 }); - }); - it('defaults to rdfSerializer preferences when given no output preferences.', async(): Promise => { - await expect(new QuadToRdfConverter().getOutputTypes()) + await expect(new QuadToRdfConverter().getOutputTypes(INTERNAL_QUADS)) .resolves.toEqual(await rdfSerializer.getContentTypesPrioritized()); }); it('supports overriding output preferences.', async(): Promise => { const outputPreferences = { 'text/turtle': 1 }; - await expect(new QuadToRdfConverter({ outputPreferences }).getOutputTypes()) + await expect(new QuadToRdfConverter({ outputPreferences }).getOutputTypes(INTERNAL_QUADS)) .resolves.toEqual(outputPreferences); }); diff --git a/test/unit/storage/conversion/RdfToQuadConverter.test.ts b/test/unit/storage/conversion/RdfToQuadConverter.test.ts index a197eba98..6a04cc867 100644 --- a/test/unit/storage/conversion/RdfToQuadConverter.test.ts +++ b/test/unit/storage/conversion/RdfToQuadConverter.test.ts @@ -16,12 +16,11 @@ describe('A RdfToQuadConverter', (): void => { const converter = new RdfToQuadConverter(); const identifier: ResourceIdentifier = { path: 'path' }; - it('supports parsing the same types as rdfParser.', async(): Promise => { - await expect(converter.getInputTypes()).resolves.toEqual(await rdfParser.getContentTypesPrioritized()); - }); - it('supports serializing as quads.', async(): Promise => { - await expect(converter.getOutputTypes()).resolves.toEqual({ [INTERNAL_QUADS]: 1 }); + const types = Object.entries(await rdfParser.getContentTypesPrioritized()); + for (const [ type, weight ] of types) { + await expect(converter.getOutputTypes(type)).resolves.toEqual({ [INTERNAL_QUADS]: weight }); + } }); it('can handle turtle to quad conversions.', async(): Promise => { From fdd42bb7b3efda8bfac535ef4ff07f45ea4a524a Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 26 Oct 2021 16:55:34 +0200 Subject: [PATCH 03/39] feat: Add ContentTypeReplacer to conversion chain --- .../representation-conversion/default.json | 2 +- src/storage/conversion/ContentTypeReplacer.ts | 23 +++++++++++-------- .../conversion/ContentTypeReplacer.test.ts | 10 ++++++++ 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/config/util/representation-conversion/default.json b/config/util/representation-conversion/default.json index 32e972d69..9cd2e64e4 100644 --- a/config/util/representation-conversion/default.json +++ b/config/util/representation-conversion/default.json @@ -21,12 +21,12 @@ "@type": "IfNeededConverter", "comment": "Only continue converting if the requester cannot accept the available content type" }, - { "@id": "urn:solid-server:default:ContentTypeReplacer" }, { "comment": "Automatically finds a path through a set of converters from one type to another.", "@id": "urn:solid-server:default:ChainedConverter", "@type": "ChainedConverter", "converters": [ + { "@id": "urn:solid-server:default:ContentTypeReplacer" }, { "@id": "urn:solid-server:default:RdfToQuadConverter" }, { "@id": "urn:solid-server:default:QuadToRdfConverter" }, { "@id": "urn:solid-server:default:ContainerToTemplateConverter" }, diff --git a/src/storage/conversion/ContentTypeReplacer.ts b/src/storage/conversion/ContentTypeReplacer.ts index 1e82f4b0c..52952f26d 100644 --- a/src/storage/conversion/ContentTypeReplacer.ts +++ b/src/storage/conversion/ContentTypeReplacer.ts @@ -4,7 +4,7 @@ import type { ValuePreferences } from '../../http/representation/RepresentationP import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { matchesMediaType, getConversionTarget } from './ConversionUtil'; import type { RepresentationConverterArgs } from './RepresentationConverter'; -import { RepresentationConverter } from './RepresentationConverter'; +import { TypedRepresentationConverter } from './TypedRepresentationConverter'; /** * A {@link RepresentationConverter} that changes the content type @@ -13,7 +13,7 @@ import { RepresentationConverter } from './RepresentationConverter'; * Useful for when a content type is binary-compatible with another one; * for instance, all JSON-LD files are valid JSON files. */ -export class ContentTypeReplacer extends RepresentationConverter { +export class ContentTypeReplacer extends TypedRepresentationConverter { private readonly contentTypeMap: Record = {}; /** @@ -40,15 +40,22 @@ export class ContentTypeReplacer extends RepresentationConverter { } } + public async getOutputTypes(contentType: string): Promise { + const supported = Object.keys(this.contentTypeMap) + .filter((type): boolean => matchesMediaType(contentType, type)) + .map((type): ValuePreferences => this.contentTypeMap[type]); + return Object.assign({} as ValuePreferences, ...supported); + } + public async canHandle({ representation, preferences }: RepresentationConverterArgs): Promise { - this.getReplacementType(representation.metadata.contentType, preferences.type); + await this.getReplacementType(representation.metadata.contentType, preferences.type); } /** * Changes the content type on the representation. */ public async handle({ representation, preferences }: RepresentationConverterArgs): Promise { - const contentType = this.getReplacementType(representation.metadata.contentType, preferences.type); + const contentType = await this.getReplacementType(representation.metadata.contentType, preferences.type); const metadata = new RepresentationMetadata(representation.metadata, contentType); return { ...representation, metadata }; } @@ -61,11 +68,9 @@ export class ContentTypeReplacer extends RepresentationConverter { * Find a replacement content type that matches the preferences, * or throws an error if none was found. */ - private getReplacementType(contentType = 'unknown', preferred: ValuePreferences = {}): string { - const supported = Object.keys(this.contentTypeMap) - .filter((type): boolean => matchesMediaType(contentType, type)) - .map((type): ValuePreferences => this.contentTypeMap[type]); - const match = getConversionTarget(Object.assign({} as ValuePreferences, ...supported), preferred); + private async getReplacementType(contentType = 'unknown', preferred: ValuePreferences = {}): Promise { + const supported = await this.getOutputTypes(contentType); + const match = getConversionTarget(supported, preferred); if (!match) { throw new NotImplementedHttpError(`Cannot convert from ${contentType} to ${Object.keys(preferred)}`); } diff --git a/test/unit/storage/conversion/ContentTypeReplacer.test.ts b/test/unit/storage/conversion/ContentTypeReplacer.test.ts index 782d5b53b..77768d332 100644 --- a/test/unit/storage/conversion/ContentTypeReplacer.test.ts +++ b/test/unit/storage/conversion/ContentTypeReplacer.test.ts @@ -97,4 +97,14 @@ describe('A ContentTypeReplacer', (): void => { expect(result.data).toBe(data); expect(result.metadata.contentType).toBe('application/trig'); }); + + it('returns all matching output types.', async(): Promise => { + await expect(converter.getOutputTypes('application/n-triples')).resolves.toEqual({ + 'text/turtle': 1, + 'application/trig': 1, + 'application/n-quads': 1, + 'application/octet-stream': 1, + 'internal/anything': 1, + }); + }); }); From d52aa94e535768c183589179462af95814b51094 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Thu, 28 Oct 2021 14:03:03 +0200 Subject: [PATCH 04/39] feat: Add "no conversion" as possible path in ChainedConverter --- .../representation-conversion/default.json | 5 - src/index.ts | 1 - src/storage/conversion/ChainedConverter.ts | 187 +++++++++++------- src/storage/conversion/IfNeededConverter.ts | 60 ------ .../conversion/ChainedConverter.test.ts | 27 ++- .../conversion/IfNeededConverter.test.ts | 131 ------------ 6 files changed, 136 insertions(+), 275 deletions(-) delete mode 100644 src/storage/conversion/IfNeededConverter.ts delete mode 100644 test/unit/storage/conversion/IfNeededConverter.test.ts diff --git a/config/util/representation-conversion/default.json b/config/util/representation-conversion/default.json index 9cd2e64e4..3731ff557 100644 --- a/config/util/representation-conversion/default.json +++ b/config/util/representation-conversion/default.json @@ -15,12 +15,7 @@ "@id": "urn:solid-server:default:RepresentationConverter", "@type": "WaterfallHandler", "handlers": [ - { "@id": "urn:solid-server:default:MarkdownToHtmlConverter" }, { "@id": "urn:solid-server:default:DynamicJsonToTemplateConverter" }, - { - "@type": "IfNeededConverter", - "comment": "Only continue converting if the requester cannot accept the available content type" - }, { "comment": "Automatically finds a path through a set of converters from one type to another.", "@id": "urn:solid-server:default:ChainedConverter", diff --git a/src/index.ts b/src/index.ts index 1fe5047ce..5352a0bf6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -261,7 +261,6 @@ export * from './storage/conversion/ErrorToJsonConverter'; export * from './storage/conversion/ErrorToQuadConverter'; export * from './storage/conversion/ErrorToTemplateConverter'; export * from './storage/conversion/FormToJsonConverter'; -export * from './storage/conversion/IfNeededConverter'; export * from './storage/conversion/MarkdownToHtmlConverter'; export * from './storage/conversion/PassthroughConverter'; export * from './storage/conversion/QuadToRdfConverter'; diff --git a/src/storage/conversion/ChainedConverter.ts b/src/storage/conversion/ChainedConverter.ts index 569727412..4704385d1 100644 --- a/src/storage/conversion/ChainedConverter.ts +++ b/src/storage/conversion/ChainedConverter.ts @@ -1,5 +1,7 @@ +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; import type { Representation } from '../../http/representation/Representation'; -import type { ValuePreference, ValuePreferences } from '../../http/representation/RepresentationPreferences'; +import { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ValuePreferences } from '../../http/representation/RepresentationPreferences'; import { getLoggerFor } from '../../logging/LogUtil'; import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; @@ -14,24 +16,35 @@ type ConverterPreference = { outTypes: ValuePreferences; }; -/** - * A chain of converters that can go from `inTypes` to `outTypes`. - * `intermediateTypes` contains the exact types that have the highest weight when going from converter i to i+1. - */ type ConversionPath = { + /** + * List of converters used in the path. + */ converters: TypedRepresentationConverter[]; + /** + * The intermediate conversion types when going from converter i to i+1. + * Length is one less than the list of converters. + */ intermediateTypes: string[]; + /** + * The type on which this conversion path starts. + */ inType: string; + /** + * The types this path can generate. + * Weights indicate the quality of transforming to that specific type. + */ outTypes: ValuePreferences; -}; - -/** - * The result of choosing a specific output for a `ConversionPath`. - */ -type MatchedPath = { - path: ConversionPath; - outType: string; + /** + * The weight of the path matched against the output preferences. + * Will be 0 if the path does not match any of those preferences. + */ weight: number; + /** + * The output type for which this path has the highest weight. + * Value is irrelevant if weight is 0. + */ + outType: string; }; /** @@ -52,6 +65,9 @@ type MatchedPath = { * - The algorithm could start on both ends of a possible path and work towards the middle. * - When creating a path, store the list of unused converters instead of checking every step. * - Caching: https://github.com/solid/community-server/issues/832 + * - Making sure each intermediate type is only used once. + * - The TypedRepresentationConverter interface could potentially be updated + * so paths only differing in intermediate types can be combined. */ export class ChainedConverter extends RepresentationConverter { protected readonly logger = getLoggerFor(this); @@ -76,43 +92,25 @@ export class ChainedConverter extends RepresentationConverter { public async handle(input: RepresentationConverterArgs): Promise { const match = await this.findPath(input); - // No conversion needed - if (!this.isMatchedPath(match)) { - return input.representation; - } - - const { path, outType } = match; - this.logger.debug(`Converting ${path.inType} -> ${[ ...path.intermediateTypes, outType ].join(' -> ')}.`); + this.logger.debug(`Converting ${match.inType} -> ${[ ...match.intermediateTypes, match.outType ].join(' -> ')}.`); const args = { ...input }; - for (let i = 0; i < path.converters.length - 1; ++i) { - const type = path.intermediateTypes[i]; - args.preferences = { type: { [type]: 1 }}; - args.representation = await path.converters[i].handle(args); + const outTypes = [ ...match.intermediateTypes, match.outType ]; + for (let i = 0; i < match.converters.length; ++i) { + args.preferences = { type: { [outTypes[i]]: 1 }}; + args.representation = await match.converters[i].handle(args); } - // For the last converter we set the preferences to the best output type - args.preferences = { type: { [outType]: 1 }}; - return path.converters.slice(-1)[0].handle(args); - } - - private isMatchedPath(path: unknown): path is MatchedPath { - return typeof (path as MatchedPath).path === 'object'; + return args.representation; } /** * Finds a conversion path that can handle the given input. */ - private async findPath(input: RepresentationConverterArgs): Promise { + private async findPath(input: RepresentationConverterArgs): Promise { const type = input.representation.metadata.contentType!; const preferences = cleanPreferences(input.preferences.type); - const weight = getTypeWeight(type, preferences); - if (weight > 0) { - this.logger.debug(`No conversion required: ${type} already matches ${preferencesToString(preferences)}`); - return { value: type, weight }; - } - - return this.generatePath(type, preferences); + return this.generatePath(type, preferences, input.representation.metadata); } /** @@ -121,33 +119,33 @@ export class ChainedConverter extends RepresentationConverter { * * Errors if such a path does not exist. */ - private async generatePath(inType: string, outPreferences: ValuePreferences): Promise { - // Generate paths from all converters that match the input type - let paths = await this.converters.reduce(async(matches: Promise, converter): - Promise => { - const outTypes = await converter.getOutputTypes(inType); - if (Object.keys(outTypes).length > 0) { - (await matches).push({ - converters: [ converter ], - intermediateTypes: [], - inType, - outTypes, - }); - } - return matches; - }, Promise.resolve([])); + private async generatePath(inType: string, outPreferences: ValuePreferences, metadata: RepresentationMetadata): + Promise { + // + const weight = getTypeWeight(inType, outPreferences); + let paths: ConversionPath[] = [{ + converters: [], + intermediateTypes: [], + inType, + outTypes: { [inType]: 1 }, + weight, + outType: inType, + }]; // It's impossible for a path to have a higher weight than this value const maxWeight = Math.max(...Object.values(outPreferences)); - let bestPath = this.findBest(outPreferences, paths); - paths = this.removeBadPaths(paths, maxWeight, bestPath); + // This metadata will be used to simulate canHandle checks + const metadataPlaceholder = new RepresentationMetadata(metadata); + + let bestPath = this.findBest(paths); // This will always stop at some point since paths can't have the same converter twice while (paths.length > 0) { // For every path, find all the paths that can be made by adding 1 more converter - const promises = paths.map(async(path): Promise => this.takeStep(path)); + const promises = paths.map(async(path): Promise => this.takeStep(path, metadataPlaceholder)); paths = (await Promise.all(promises)).flat(); - const newBest = this.findBest(outPreferences, paths); + this.updatePathWeights(paths, outPreferences); + const newBest = this.findBest(paths); if (newBest && (!bestPath || newBest.weight > bestPath.weight)) { bestPath = newBest; } @@ -155,26 +153,37 @@ export class ChainedConverter extends RepresentationConverter { } if (!bestPath) { - this.logger.warn(`No conversion path could be made from ${inType} to ${Object.keys(outPreferences)}.`); + this.logger.warn(`No conversion path could be made from ${inType} to ${preferencesToString(outPreferences)}.`); throw new NotImplementedHttpError( - `No conversion path could be made from ${inType} to ${Object.keys(outPreferences)}.`, + `No conversion path could be made from ${inType} to ${preferencesToString(outPreferences)}.`, ); } return bestPath; } + /** + * Checks if a path can match the requested preferences and updates the type and weight if it can. + */ + private updatePathWeights(paths: ConversionPath[], outPreferences: ValuePreferences): void { + for (const path of paths) { + const outMatch = getBestPreference(path.outTypes, outPreferences); + if (outMatch && outMatch.weight > 0) { + path.weight = outMatch.weight; + path.outType = outMatch.value; + } + } + } + /** * Finds the path from the given list that can convert to the given preferences. * If there are multiple matches the one with the highest result weight gets chosen. * Will return undefined if there are no matches. */ - private findBest(preferences: ValuePreferences, paths: ConversionPath[]): MatchedPath | undefined { + private findBest(paths: ConversionPath[]): ConversionPath | undefined { // Need to use null instead of undefined so `reduce` doesn't take the first element of the array as `best` - return paths.reduce((best: MatchedPath | null, path): MatchedPath | null => { - const outMatch = getBestPreference(path.outTypes, preferences); - if (outMatch && !(best && best.weight >= outMatch.weight)) { - // Create new MatchedPath, using the output match above - return { path, outType: outMatch.value, weight: outMatch.weight }; + return paths.reduce((best: ConversionPath | null, path): ConversionPath | null => { + if (path.weight > 0 && !(best && best.weight >= path.weight)) { + return path; } return best; }, null) ?? undefined; @@ -188,7 +197,7 @@ export class ChainedConverter extends RepresentationConverter { * @param maxWeight - The maximum weight in the output preferences. * @param bestMatch - The current best path. */ - private removeBadPaths(paths: ConversionPath[], maxWeight: number, bestMatch?: MatchedPath): ConversionPath[] { + private removeBadPaths(paths: ConversionPath[], maxWeight: number, bestMatch?: ConversionPath): ConversionPath[] { // All paths are still good if there is no best match yet if (!bestMatch) { return paths; @@ -209,16 +218,19 @@ export class ChainedConverter extends RepresentationConverter { * Finds all converters that could take the output of the given path as input. * For each of these converters a new path gets created which is the input path appended by the converter. */ - private async takeStep(path: ConversionPath): Promise { + private async takeStep(path: ConversionPath, metadata: RepresentationMetadata): Promise { const unusedConverters = this.converters.filter((converter): boolean => !path.converters.includes(converter)); - const nextConverters = await this.supportedConverters(path.outTypes, unusedConverters); + const nextConverters = await this.supportedConverters(path.outTypes, metadata, unusedConverters); // Create a new path for every converter that can be appended return Promise.all(nextConverters.map(async(pref): Promise => ({ converters: [ ...path.converters, pref.converter ], - intermediateTypes: [ ...path.intermediateTypes, pref.inType ], + intermediateTypes: path.converters.length > 0 ? [ ...path.intermediateTypes, pref.inType ] : [], inType: path.inType, outTypes: pref.outTypes, + // These will be updated later + weight: 0, + outType: 'invalid', }))); } @@ -232,18 +244,43 @@ export class ChainedConverter extends RepresentationConverter { /** * Finds all converters in the given list that support taking any of the given types as input. + * Filters out converters that would produce an already seen type. */ - private async supportedConverters(types: ValuePreferences, converters: TypedRepresentationConverter[]): - Promise { + private async supportedConverters(types: ValuePreferences, metadata: RepresentationMetadata, + converters: TypedRepresentationConverter[]): Promise { const typeEntries = Object.entries(types); const results: ConverterPreference[] = []; for (const converter of converters) { for (const [ inType, weight ] of typeEntries) { - let outTypes = await converter.getOutputTypes(inType); - outTypes = this.modifyTypeWeights(weight, outTypes); - results.push({ converter, inType, outTypes }); + // This metadata object is only used internally so changing the content-type is fine + metadata.contentType = inType; + const preference = await this.findConverterPreference(inType, weight, metadata, converter); + if (preference) { + results.push(preference); + } } } return results; } + + /** + * Returns a ConverterPreference if the given converter supports the given type. + * All types that have already been used will be removed from the output types. + */ + private async findConverterPreference(inType: string, weight: number, metadata: RepresentationMetadata, + converter: TypedRepresentationConverter): Promise { + const representation = new BasicRepresentation([], metadata); + try { + const identifier = { path: representation.metadata.identifier.value }; + // Internal types get ignored when trying to match everything, so they need to be specified to also match. + await converter.canHandle({ representation, identifier, preferences: { type: { '*/*': 1, 'internal/*': 1 }}}); + } catch { + // Skip converters that fail the canHandle test + return; + } + + let outTypes = await converter.getOutputTypes(inType); + outTypes = this.modifyTypeWeights(weight, outTypes); + return { converter, inType, outTypes }; + } } diff --git a/src/storage/conversion/IfNeededConverter.ts b/src/storage/conversion/IfNeededConverter.ts deleted file mode 100644 index 547cfa67b..000000000 --- a/src/storage/conversion/IfNeededConverter.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Representation } from '../../http/representation/Representation'; -import { getLoggerFor } from '../../logging/LogUtil'; -import { InternalServerError } from '../../util/errors/InternalServerError'; -import { UnsupportedAsyncHandler } from '../../util/handlers/UnsupportedAsyncHandler'; -import { matchesMediaPreferences } from './ConversionUtil'; -import { RepresentationConverter } from './RepresentationConverter'; -import type { RepresentationConverterArgs } from './RepresentationConverter'; - -const EMPTY_CONVERTER = new UnsupportedAsyncHandler('The content type does not match the preferences'); - -/** - * A {@link RepresentationConverter} that only converts representations - * that are not compatible with the preferences. - */ -export class IfNeededConverter extends RepresentationConverter { - private readonly converter: RepresentationConverter; - protected readonly logger = getLoggerFor(this); - - public constructor(converter: RepresentationConverter = EMPTY_CONVERTER) { - super(); - this.converter = converter; - } - - public async canHandle(args: RepresentationConverterArgs): Promise { - if (this.needsConversion(args)) { - await this.converter.canHandle(args); - } - } - - public async handle(args: RepresentationConverterArgs): Promise { - return !this.needsConversion(args) ? args.representation : this.convert(args, false); - } - - public async handleSafe(args: RepresentationConverterArgs): Promise { - return !this.needsConversion(args) ? args.representation : this.convert(args, true); - } - - protected needsConversion({ identifier, representation, preferences }: RepresentationConverterArgs): boolean { - // No conversion is needed if there are any matches for the provided content type - const { contentType } = representation.metadata; - if (!contentType) { - throw new InternalServerError('Content-Type is required for data conversion.'); - } - const noMatchingMediaType = !matchesMediaPreferences(contentType, preferences.type); - if (noMatchingMediaType) { - this.logger.debug(`Conversion needed for ${identifier - .path} from ${contentType} to satisfy ${!preferences.type ? - '""' : - Object.entries(preferences.type).map(([ value, weight ]): string => `${value};q=${weight}`).join(', ')}`); - } - return noMatchingMediaType; - } - - protected async convert(args: RepresentationConverterArgs, safely: boolean): Promise { - const converted = await (safely ? this.converter.handleSafe(args) : this.converter.handle(args)); - this.logger.info(`Converted representation for ${args.identifier - .path} from ${args.representation.metadata.contentType} to ${converted.metadata.contentType}`); - return converted; - } -} diff --git a/test/unit/storage/conversion/ChainedConverter.test.ts b/test/unit/storage/conversion/ChainedConverter.test.ts index d75c1a212..01d1dbf4e 100644 --- a/test/unit/storage/conversion/ChainedConverter.test.ts +++ b/test/unit/storage/conversion/ChainedConverter.test.ts @@ -70,19 +70,29 @@ describe('A ChainedConverter', (): void => { args.representation.metadata.contentType = 'b/b'; await expect(converter.handle(args)).rejects - .toThrow('No conversion path could be made from b/b to x/x,x/*,internal/*.'); + .toThrow('No conversion path could be made from b/b to x/x:1,x/*:0.8,internal/*:0.'); }); it('can handle situations where no conversion is required.', async(): Promise => { - const converters = [ new DummyConverter({ 'a/a': 1 }, { 'x/x': 1 }) ]; + const converters = [ new DummyConverter({ 'b/b': 1 }, { 'x/x': 1 }) ]; args.representation.metadata.contentType = 'b/b'; - args.preferences.type = { 'b/*': 0.5 }; + args.preferences.type = { 'b/*': 1, 'x/x': 0.5 }; const converter = new ChainedConverter(converters); const result = await converter.handle(args); expect(result.metadata.contentType).toBe('b/b'); }); + it('converts input matching the output preferences if a better output can be found.', async(): Promise => { + const converters = [ new DummyConverter({ 'b/b': 1 }, { 'x/x': 1 }) ]; + args.representation.metadata.contentType = 'b/b'; + args.preferences.type = { 'b/*': 0.5, 'x/x': 1 }; + const converter = new ChainedConverter(converters); + + const result = await converter.handle(args); + expect(result.metadata.contentType).toBe('x/x'); + }); + it('interprets no preferences as */*.', async(): Promise => { const converters = [ new DummyConverter({ 'a/a': 1 }, { 'x/x': 1 }) ]; const converter = new ChainedConverter(converters); @@ -210,4 +220,15 @@ describe('A ChainedConverter', (): void => { expect(converter.handle).toHaveBeenCalledTimes(1); expect(converter.handle).toHaveBeenLastCalledWith(args); }); + + it('does not get stuck in infinite conversion loops.', async(): Promise => { + const converters = [ + new DummyConverter({ 'a/a': 1 }, { 'b/b': 1 }), + new DummyConverter({ 'b/b': 1 }, { 'a/a': 1 }), + ]; + const converter = new ChainedConverter(converters); + + await expect(converter.handle(args)).rejects + .toThrow('No conversion path could be made from a/a to x/x:1,x/*:0.8,internal/*:0.'); + }); }); diff --git a/test/unit/storage/conversion/IfNeededConverter.test.ts b/test/unit/storage/conversion/IfNeededConverter.test.ts deleted file mode 100644 index af5642d79..000000000 --- a/test/unit/storage/conversion/IfNeededConverter.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { Representation } from '../../../../src/http/representation/Representation'; -import { IfNeededConverter } from '../../../../src/storage/conversion/IfNeededConverter'; -import type { - RepresentationConverter, -} from '../../../../src/storage/conversion/RepresentationConverter'; - -describe('An IfNeededConverter', (): void => { - const identifier = { path: 'identifier' }; - const representation: Representation = { - metadata: { contentType: 'text/turtle' }, - } as any; - const converted = { - metadata: { contentType: 'application/ld+json' }, - }; - - const innerConverter: jest.Mocked = { - canHandle: jest.fn().mockResolvedValue(true), - handle: jest.fn().mockResolvedValue(converted), - handleSafe: jest.fn().mockResolvedValue(converted), - } as any; - - const converter = new IfNeededConverter(innerConverter); - - afterEach((): void => { - jest.clearAllMocks(); - }); - - it('performs no conversion when there are no content type preferences.', async(): Promise => { - const preferences = {}; - const args = { identifier, representation, preferences }; - - await expect(converter.canHandle(args)).resolves.toBeUndefined(); - await expect(converter.handle(args)).resolves.toBe(representation); - await expect(converter.handleSafe(args)).resolves.toBe(representation); - - expect(innerConverter.canHandle).toHaveBeenCalledTimes(0); - expect(innerConverter.handle).toHaveBeenCalledTimes(0); - expect(innerConverter.handleSafe).toHaveBeenCalledTimes(0); - }); - - it('performs conversion when there are no preferences but the content-type is internal.', async(): Promise => { - const preferences = {}; - const internalRepresentation = { - metadata: { contentType: 'internal/quads' }, - } as any; - const args = { identifier, representation: internalRepresentation, preferences }; - - await expect(converter.handleSafe(args)).resolves.toBe(converted); - - expect(innerConverter.canHandle).toHaveBeenCalledTimes(0); - expect(innerConverter.handle).toHaveBeenCalledTimes(0); - expect(innerConverter.handleSafe).toHaveBeenCalledTimes(1); - expect(innerConverter.handleSafe).toHaveBeenCalledWith(args); - }); - - it('errors if no content type is specified on the representation.', async(): Promise => { - const preferences = { type: { 'text/turtle': 1 }}; - const args = { identifier, representation: { metadata: {}} as any, preferences }; - - await expect(converter.handleSafe(args)).rejects - .toThrow('Content-Type is required for data conversion.'); - - expect(innerConverter.canHandle).toHaveBeenCalledTimes(0); - expect(innerConverter.handle).toHaveBeenCalledTimes(0); - expect(innerConverter.handleSafe).toHaveBeenCalledTimes(0); - }); - - it('performs no conversion when the content type matches the preferences.', async(): Promise => { - const preferences = { type: { 'text/turtle': 1 }}; - const args = { identifier, representation, preferences }; - - await expect(converter.handleSafe(args)).resolves.toBe(representation); - - expect(innerConverter.canHandle).toHaveBeenCalledTimes(0); - expect(innerConverter.handle).toHaveBeenCalledTimes(0); - expect(innerConverter.handleSafe).toHaveBeenCalledTimes(0); - }); - - it('performs a conversion when the content type matches the preferences.', async(): Promise => { - const preferences = { type: { 'text/turtle': 0 }}; - const args = { identifier, representation, preferences }; - - await expect(converter.handleSafe(args)).resolves.toBe(converted); - - expect(innerConverter.canHandle).toHaveBeenCalledTimes(0); - expect(innerConverter.handle).toHaveBeenCalledTimes(0); - expect(innerConverter.handleSafe).toHaveBeenCalledTimes(1); - expect(innerConverter.handleSafe).toHaveBeenCalledWith(args); - }); - - it('does not support conversion when the inner converter does not support it.', async(): Promise => { - const preferences = { type: { 'text/turtle': 0 }}; - const args = { identifier, representation, preferences }; - const error = new Error('unsupported'); - innerConverter.canHandle.mockRejectedValueOnce(error); - - await expect(converter.canHandle(args)).rejects.toThrow(error); - - expect(innerConverter.canHandle).toHaveBeenCalledTimes(1); - expect(innerConverter.canHandle).toHaveBeenCalledWith(args); - }); - - it('supports conversion when the inner converter supports it.', async(): Promise => { - const preferences = { type: { 'text/turtle': 0 }}; - const args = { identifier, representation, preferences }; - - await expect(converter.canHandle(args)).resolves.toBeUndefined(); - - expect(innerConverter.canHandle).toHaveBeenCalledTimes(1); - expect(innerConverter.canHandle).toHaveBeenCalledWith(args); - - await expect(converter.handle(args)).resolves.toBe(converted); - - expect(innerConverter.canHandle).toHaveBeenCalledTimes(1); - expect(innerConverter.handle).toHaveBeenCalledTimes(1); - expect(innerConverter.handle).toHaveBeenCalledWith(args); - }); - - it('does not support conversion when there is no inner converter.', async(): Promise => { - const emptyConverter = new IfNeededConverter(); - const preferences = { type: { 'text/turtle': 0 }}; - const args = { identifier, representation, preferences }; - - await expect(emptyConverter.canHandle(args)).rejects - .toThrow('The content type does not match the preferences'); - await expect(emptyConverter.handle(args)).rejects - .toThrow('The content type does not match the preferences'); - await expect(emptyConverter.handleSafe(args)).rejects - .toThrow('The content type does not match the preferences'); - }); -}); From ed287ffade1b5b3d4621f97ad559bb72479eaaf6 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Thu, 28 Oct 2021 15:35:48 +0200 Subject: [PATCH 05/39] test: Add content negotiation integration tests --- test/integration/ContentNegotiation.test.ts | 67 +++++++++++++++++++ .../RepresentationConverter.test.ts | 47 ------------- test/util/Util.ts | 1 + 3 files changed, 68 insertions(+), 47 deletions(-) create mode 100644 test/integration/ContentNegotiation.test.ts delete mode 100644 test/integration/RepresentationConverter.test.ts diff --git a/test/integration/ContentNegotiation.test.ts b/test/integration/ContentNegotiation.test.ts new file mode 100644 index 000000000..8573df15b --- /dev/null +++ b/test/integration/ContentNegotiation.test.ts @@ -0,0 +1,67 @@ +import assert from 'assert'; +import fetch from 'cross-fetch'; +import type { App } from '../../src/init/App'; +import { getPort } from '../util/Util'; +import { getDefaultVariables, getTestConfigPath, instantiateFromConfig } from './Config'; + +const port = getPort('ContentNegotiation'); +const baseUrl = `http://localhost:${port}`; + +const documents = [ + [ '/turtle', 'text/turtle', '# Test' ], + [ '/markdown', 'text/markdown', '# Test' ], +]; + +const cases: [string, string, string][] = [ + [ '/turtle', 'text/turtle', '' ], + [ '/turtle', 'text/turtle', '*/*' ], + [ '/turtle', 'text/turtle', 'text/html,*/*;q=0.1' ], + [ '/turtle', 'application/json', 'application/json' ], + [ '/turtle', 'application/ld+json', 'application/ld+json' ], + [ '/turtle', 'application/octet-stream', 'application/octet-stream' ], + [ '/markdown', 'text/markdown', '' ], + [ '/markdown', 'text/markdown', '*/*' ], + [ '/markdown', 'text/markdown', 'text/html,text/markdown' ], + [ '/markdown', 'text/markdown', 'text/markdown;q=0.9, text/html;q=0.1' ], + [ '/markdown', 'text/html', 'text/html' ], + [ '/markdown', 'text/html', 'text/html,*/*;q=0.8' ], + [ '/markdown', 'text/html', 'text/markdown;q=0.1, text/html;q=0.9' ], + [ '/markdown', 'application/octet-stream', 'application/octet-stream' ], +]; + +describe('Content negotiation', (): void => { + let app: App; + + beforeAll(async(): Promise => { + // Start the server + const instances = await instantiateFromConfig( + 'urn:solid-server:test:Instances', + getTestConfigPath('server-memory.json'), + getDefaultVariables(port, baseUrl), + ) as Record; + ({ app } = instances); + await app.start(); + + // Create documents + for (const [ slug, contentType, body ] of documents) { + const res = await fetch(`${baseUrl}${slug}`, { + method: 'PUT', + headers: { 'content-type': contentType }, + body, + }); + assert.strictEqual(res.status, 201); + } + }); + + afterAll(async(): Promise => { + await app.stop(); + }); + + describe.each(cases)('a request for %s', (name, expected, accept): void => { + it(`results in ${expected} in response to Accept: ${accept}`, async(): Promise => { + const res = await fetch(`${baseUrl}${name}`, { headers: { accept }}); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe(expected); + }); + }); +}); diff --git a/test/integration/RepresentationConverter.test.ts b/test/integration/RepresentationConverter.test.ts deleted file mode 100644 index 86c6c7413..000000000 --- a/test/integration/RepresentationConverter.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { BasicRepresentation } from '../../src/http/representation/BasicRepresentation'; -import { ChainedConverter } from '../../src/storage/conversion/ChainedConverter'; -import { QuadToRdfConverter } from '../../src/storage/conversion/QuadToRdfConverter'; -import { RdfToQuadConverter } from '../../src/storage/conversion/RdfToQuadConverter'; -import { readableToString } from '../../src/util/StreamUtil'; - -describe('A ChainedConverter', (): void => { - const converters = [ - new RdfToQuadConverter(), - new QuadToRdfConverter(), - ]; - const converter = new ChainedConverter(converters); - - it('can convert from JSON-LD to turtle.', async(): Promise => { - const representation = new BasicRepresentation( - '{"@id": "http://test.com/s", "http://test.com/p": { "@id": "http://test.com/o" }}', - 'application/ld+json', - ); - - const result = await converter.handleSafe({ - representation, - preferences: { type: { 'text/turtle': 1 }}, - identifier: { path: 'path' }, - }); - - await expect(readableToString(result.data)).resolves.toEqual(' .\n'); - expect(result.metadata.contentType).toEqual('text/turtle'); - }); - - it('can convert from turtle to JSON-LD.', async(): Promise => { - const representation = new BasicRepresentation( - ' .', - 'text/turtle', - ); - - const result = await converter.handleSafe({ - representation, - preferences: { type: { 'application/ld+json': 1 }}, - identifier: { path: 'path' }, - }); - - expect(JSON.parse(await readableToString(result.data))).toEqual( - [{ '@id': 'http://test.com/s', 'http://test.com/p': [{ '@id': 'http://test.com/o' }]}], - ); - expect(result.metadata.contentType).toEqual('application/ld+json'); - }); -}); diff --git a/test/util/Util.ts b/test/util/Util.ts index c87b934a4..40e652c00 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -5,6 +5,7 @@ import type { SystemError } from '../../src/util/errors/SystemError'; const portNames = [ // Integration 'Conditions', + 'ContentNegotiation', 'DynamicPods', 'Identity', 'LpdHandlerWithAuth', From c6544fac1db432d1e0ce323bf439c48a7ed5dc52 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Thu, 28 Oct 2021 16:00:06 +0200 Subject: [PATCH 06/39] fix: Prefer all inputs equally when generating quads --- src/storage/conversion/RdfToQuadConverter.ts | 2 +- test/unit/storage/conversion/RdfToQuadConverter.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/storage/conversion/RdfToQuadConverter.ts b/src/storage/conversion/RdfToQuadConverter.ts index 19295bd96..07f8f3974 100644 --- a/src/storage/conversion/RdfToQuadConverter.ts +++ b/src/storage/conversion/RdfToQuadConverter.ts @@ -13,7 +13,7 @@ import type { RepresentationConverterArgs } from './RepresentationConverter'; */ export class RdfToQuadConverter extends BaseTypedRepresentationConverter { public constructor() { - super(rdfParser.getContentTypesPrioritized(), INTERNAL_QUADS); + super(rdfParser.getContentTypes(), INTERNAL_QUADS); } public async handle({ representation, identifier }: RepresentationConverterArgs): Promise { diff --git a/test/unit/storage/conversion/RdfToQuadConverter.test.ts b/test/unit/storage/conversion/RdfToQuadConverter.test.ts index 6a04cc867..e4f66db8b 100644 --- a/test/unit/storage/conversion/RdfToQuadConverter.test.ts +++ b/test/unit/storage/conversion/RdfToQuadConverter.test.ts @@ -17,9 +17,9 @@ describe('A RdfToQuadConverter', (): void => { const identifier: ResourceIdentifier = { path: 'path' }; it('supports serializing as quads.', async(): Promise => { - const types = Object.entries(await rdfParser.getContentTypesPrioritized()); - for (const [ type, weight ] of types) { - await expect(converter.getOutputTypes(type)).resolves.toEqual({ [INTERNAL_QUADS]: weight }); + const types = await rdfParser.getContentTypes(); + for (const type of types) { + await expect(converter.getOutputTypes(type)).resolves.toEqual({ [INTERNAL_QUADS]: 1 }); } }); From 4d319d2564e953514c94cbadf93e28fefc501e86 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Fri, 5 Nov 2021 14:12:49 +0100 Subject: [PATCH 07/39] fix: Handle JSON preferences correctly in dynamic converter --- .../DynamicJsonToTemplateConverter.ts | 18 +++++++++++++++ .../DynamicJsonToTemplateConverter.test.ts | 23 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/storage/conversion/DynamicJsonToTemplateConverter.ts b/src/storage/conversion/DynamicJsonToTemplateConverter.ts index 975c34106..7fe90afbb 100644 --- a/src/storage/conversion/DynamicJsonToTemplateConverter.ts +++ b/src/storage/conversion/DynamicJsonToTemplateConverter.ts @@ -24,6 +24,8 @@ import type { RepresentationConverterArgs } from './RepresentationConverter'; * describing the content-type of that template. * * The output of the result depends on the content-type matched with the template. + * In case JSON is the most preferred output type, + * the input representation will be returned unless a JSON template is defined. */ export class DynamicJsonToTemplateConverter extends RepresentationConverter { private readonly templateEngine: TemplateEngine; @@ -51,6 +53,11 @@ export class DynamicJsonToTemplateConverter extends RepresentationConverter { const typeMap = this.constructTypeMap(identifier, representation); const type = this.findType(typeMap, preferences.type); + // No conversion needed if JSON is requested and there is no specific JSON template + if (type === APPLICATION_JSON && typeMap[APPLICATION_JSON].length === 0) { + return representation; + } + const json = JSON.parse(await readableToString(representation.data)); const rendered = await this.templateEngine.render(json, { templateFile: typeMap[type] }); @@ -69,6 +76,11 @@ export class DynamicJsonToTemplateConverter extends RepresentationConverter { .map((quad): Term => quad.object) .filter((term: Term): boolean => term.termType === 'NamedNode') as NamedNode[]; + // This handler should only cover cases where templates are defined + if (templates.length === 0) { + throw new NotImplementedHttpError('No templates found.'); + } + // Maps all content-types to their template const typeMap: Record = {}; for (const template of templates) { @@ -77,6 +89,12 @@ export class DynamicJsonToTemplateConverter extends RepresentationConverter { typeMap[type] = template.value; } } + + // Not using a template is always an option unless there is a specific JSON template + if (!typeMap[APPLICATION_JSON]) { + typeMap[APPLICATION_JSON] = ''; + } + return typeMap; } diff --git a/test/unit/storage/conversion/DynamicJsonToTemplateConverter.test.ts b/test/unit/storage/conversion/DynamicJsonToTemplateConverter.test.ts index e63ce6a9e..17e59f7a3 100644 --- a/test/unit/storage/conversion/DynamicJsonToTemplateConverter.test.ts +++ b/test/unit/storage/conversion/DynamicJsonToTemplateConverter.test.ts @@ -1,9 +1,11 @@ import { DataFactory } from 'n3'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../../src/http/representation/Representation'; +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; import type { RepresentationPreferences } from '../../../../src/http/representation/RepresentationPreferences'; import { DynamicJsonToTemplateConverter } from '../../../../src/storage/conversion/DynamicJsonToTemplateConverter'; import type { RepresentationConverterArgs } from '../../../../src/storage/conversion/RepresentationConverter'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { readableToString } from '../../../../src/util/StreamUtil'; import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; import { CONTENT_TYPE_TERM, SOLID_META } from '../../../../src/util/Vocabularies'; @@ -50,6 +52,12 @@ describe('A DynamicJsonToTemplateConverter', (): void => { await expect(converter.canHandle(input)).resolves.toBeUndefined(); }); + it('rejects JSON input if no templates are defined.', async(): Promise => { + preferences.type = { 'application/json': 1 }; + representation.metadata = new RepresentationMetadata('application/json'); + await expect(converter.canHandle(input)).rejects.toThrow(NotImplementedHttpError); + }); + it('uses the input JSON as parameters for the matching template.', async(): Promise => { const result = await converter.handle(input); await expect(readableToString(result.data)).resolves.toBe(''); @@ -64,4 +72,19 @@ describe('A DynamicJsonToTemplateConverter', (): void => { const result = await converter.handle(input); await expect(readableToString(result.data)).resolves.toBe(''); }); + + it('returns the input representation if JSON is preferred.', async(): Promise => { + input.preferences.type = { 'application/json': 1, 'text/html': 0.5 }; + await expect(converter.handle(input)).resolves.toBe(input.representation); + }); + + it('still converts if JSON is preferred but there is a JSON template.', async(): Promise => { + input.preferences.type = { 'application/json': 1 }; + const templateNode = namedNode(templateFile); + representation.metadata.add(SOLID_META.terms.template, templateNode); + representation.metadata.addQuad(templateNode, CONTENT_TYPE_TERM, 'application/json'); + const result = await converter.handle(input); + await expect(readableToString(result.data)).resolves.toBe(''); + expect(result.metadata.contentType).toBe('application/json'); + }); }); From b0208bf4699ce96c4e30ab6603089c1d8b5f2aef Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Mon, 8 Nov 2021 09:42:58 +0100 Subject: [PATCH 08/39] docs: Update release notes with conversion changes --- RELEASE_NOTES.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c1d69e7fb..5444f4b8c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,24 @@ # Community Solid Server release notes +## v3.0.0 +### New features +... + +### Configuration changes +You might need to make changes to your v2 configuration if you use a custom config. + +The following changes pertain to the imports in the default configs: +- ... + +The following changes are relevant for v2 custom configs that replaced certain features. +- Conversion has been simplified so most converters are part of the conversion chain: + - `/util/representation-conversion/default.json` + +### Interface changes +These changes are relevant if you wrote custom modules for the server that depend on existing interfaces. +- `TypedRepresentationConverter` function signatures changed + and base functionality moved to `BaseTypedRepresentationConverter` + ## v2.0.0 ### New features - Pod owners always have Control access to resources stored in their Pod. From fc60b5c161853845d1f3e6405e1182948cca421b Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 2 Nov 2021 08:54:11 +0100 Subject: [PATCH 09/39] feat: Update IDP parameters to latest Solid-OIDC version --- RELEASE_NOTES.md | 4 +++- .../handler/provider-factory/identity.json | 16 +++++++--------- test/integration/Identity.test.ts | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5444f4b8c..1656f83eb 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,7 +2,7 @@ ## v3.0.0 ### New features -... +- The Identity Provider now uses the `webid` scope as required for Solid-OIDC. ### Configuration changes You might need to make changes to your v2 configuration if you use a custom config. @@ -13,6 +13,8 @@ The following changes pertain to the imports in the default configs: The following changes are relevant for v2 custom configs that replaced certain features. - Conversion has been simplified so most converters are part of the conversion chain: - `/util/representation-conversion/default.json` +- The IDP settings have changed to support the latest Solid-OIDC draft. + - `/identity/handler/provider-factory/identity.json` ### Interface changes These changes are relevant if you wrote custom modules for the server that depend on existing interfaces. diff --git a/config/identity/handler/provider-factory/identity.json b/config/identity/handler/provider-factory/identity.json index 1ddff6918..5c8686ee9 100644 --- a/config/identity/handler/provider-factory/identity.json +++ b/config/identity/handler/provider-factory/identity.json @@ -3,9 +3,9 @@ "@graph": [ { "comment": [ - "Sets all the relevant oidc parameters.", - "webid claim is in openid scope until an official scope has been decided: https://github.com/solid/authentication-panel/issues/86" - ], + "Sets all the relevant Solid-OIDC parameters.", + "dPoP is draft-01 since that is the latest version v6 of the OIDC library supports." + ], "@id": "urn:solid-server:default:IdentityProviderFactory", "@type": "IdentityProviderFactory", "args_adapterFactory": { "@id": "urn:solid-server:default:IdpAdapterFactory" }, @@ -16,15 +16,13 @@ "args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }, "config": { "claims": { - "openid": [ "webid", "client_id" ] + "openid": [ "client_id" ], + "webid": [ "webid" ] }, "cookies": { "long": { "signed": true, "maxAge": 86400000 }, "short": { "signed": true } }, - "discovery": { - "solid_oidc_supported": "https://solidproject.org/TR/solid-oidc" - }, "features": { "claimsParameter": { "enabled": true }, "devInteractions": { "enabled": false }, @@ -36,8 +34,8 @@ "formats": { "AccessToken": "jwt" }, - "scopes": [ "openid", "profile", "offline_access" ], - "subjectTypes": [ "public", "pairwise" ], + "scopes": [ "openid", "profile", "offline_access", "webid" ], + "subjectTypes": [ "public" ], "ttl": { "AccessToken": 3600, "AuthorizationCode": 600, diff --git a/test/integration/Identity.test.ts b/test/integration/Identity.test.ts index 41a9f868a..ccd4b925d 100644 --- a/test/integration/Identity.test.ts +++ b/test/integration/Identity.test.ts @@ -373,8 +373,8 @@ describe('A Solid server with IDP', (): void => { const jsonBody = await res.json(); expect(res.status).toBe(200); - // https://solid.github.io/authentication-panel/solid-oidc/#discovery - expect(jsonBody.solid_oidc_supported).toEqual('https://solidproject.org/TR/solid-oidc'); + // https://solid.github.io/solid-oidc/#discovery + expect(jsonBody.scopes_supported).toContain('webid'); }); it('should return correct error output.', async(): Promise => { From 520e4fe42fe14ec80ef0718c7f1214620fdae218 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 9 Nov 2021 11:55:49 +0100 Subject: [PATCH 10/39] feat: Move OIDC library behaviour to separate path --- RELEASE_NOTES.md | 3 ++ config/http/handler/default.json | 3 +- config/http/handler/handlers/oidc.json | 18 +++++++++++ config/identity/handler/default.json | 2 +- .../handler/provider-factory/identity.json | 1 + src/identity/IdentityProviderHttpHandler.ts | 19 +++--------- src/identity/OidcHttpHandler.ts | 27 ++++++++++++++++ .../configuration/IdentityProviderFactory.ts | 12 +++++-- src/index.ts | 1 + src/server/AuthorizingHttpHandler.ts | 2 +- src/server/OperationHttpHandler.ts | 4 +-- test/integration/Identity.test.ts | 2 +- .../config/server-without-auth.json | 6 +--- .../IdentityProviderHttpHandler.test.ts | 8 ++--- test/unit/identity/OidcHttpHandler.test.ts | 31 +++++++++++++++++++ .../IdentityProviderFactory.test.ts | 29 ++++++++++------- 16 files changed, 121 insertions(+), 47 deletions(-) create mode 100644 config/http/handler/handlers/oidc.json create mode 100644 src/identity/OidcHttpHandler.ts create mode 100644 test/unit/identity/OidcHttpHandler.test.ts diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1656f83eb..70bb6acde 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -15,6 +15,9 @@ The following changes are relevant for v2 custom configs that replaced certain f - `/util/representation-conversion/default.json` - The IDP settings have changed to support the latest Solid-OIDC draft. - `/identity/handler/provider-factory/identity.json` +- Requests targeting the OIDC library now use a separate handler. + - `/http/handler/default.json` + - `/identity/handler/default.json` ### Interface changes These changes are relevant if you wrote custom modules for the server that depend on existing interfaces. diff --git a/config/http/handler/default.json b/config/http/handler/default.json index 01e0b34b1..acbcfb97d 100644 --- a/config/http/handler/default.json +++ b/config/http/handler/default.json @@ -1,7 +1,7 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", "import": [ - "files-scs:config/app/init/initializers/root.json" + "files-scs:config/http/handler/handlers/oidc.json" ], "@graph": [ { @@ -15,6 +15,7 @@ "handlers": [ { "@id": "urn:solid-server:default:StaticAssetHandler" }, { "@id": "urn:solid-server:default:SetupHandler" }, + { "@id": "urn:solid-server:default:OidcHandler" }, { "@id": "urn:solid-server:default:AuthResourceHttpHandler" }, { "@id": "urn:solid-server:default:IdentityProviderHandler" }, { "@id": "urn:solid-server:default:LdpHandler" } diff --git a/config/http/handler/handlers/oidc.json b/config/http/handler/handlers/oidc.json new file mode 100644 index 000000000..e9cf0fa0f --- /dev/null +++ b/config/http/handler/handlers/oidc.json @@ -0,0 +1,18 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Routes all OIDC related requests to the OIDC library.", + "@id": "urn:solid-server:default:OidcHandler", + "@type": "RouterHandler", + "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, + "args_allowedMethods": [ "*" ], + "args_allowedPathNames": [ "^/.oidc/.*", "^/\\.well-known/openid-configuration" ], + "args_handler": { + "@type": "OidcHttpHandler", + "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" } + } + } + ] +} diff --git a/config/identity/handler/default.json b/config/identity/handler/default.json index f6104b5d3..c9b13dc4c 100644 --- a/config/identity/handler/default.json +++ b/config/identity/handler/default.json @@ -14,7 +14,7 @@ "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, "args_allowedMethods": [ "*" ], - "args_allowedPathNames": [ "^/idp/.*", "^/\\.well-known/openid-configuration" ], + "args_allowedPathNames": [ "^/idp/.*" ], "args_handler": { "@id": "urn:solid-server:default:IdentityProviderParsingHandler" } }, { diff --git a/config/identity/handler/provider-factory/identity.json b/config/identity/handler/provider-factory/identity.json index 5c8686ee9..cde397c61 100644 --- a/config/identity/handler/provider-factory/identity.json +++ b/config/identity/handler/provider-factory/identity.json @@ -10,6 +10,7 @@ "@type": "IdentityProviderFactory", "args_adapterFactory": { "@id": "urn:solid-server:default:IdpAdapterFactory" }, "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "args_oidcPath": "/.oidc", "args_idpPath": "/idp", "args_storage": { "@id": "urn:solid-server:default:IdpKeyStorage" }, "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" }, diff --git a/src/identity/IdentityProviderHttpHandler.ts b/src/identity/IdentityProviderHttpHandler.ts index 5d9f416b2..5be0a772a 100644 --- a/src/identity/IdentityProviderHttpHandler.ts +++ b/src/identity/IdentityProviderHttpHandler.ts @@ -10,6 +10,7 @@ import { OperationHttpHandler } from '../server/OperationHttpHandler'; import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter'; import { APPLICATION_JSON } from '../util/ContentTypes'; import { BadRequestHttpError } from '../util/errors/BadRequestHttpError'; +import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; import { joinUrl, trimTrailingSlashes } from '../util/PathUtil'; import { addTemplateMetadata, cloneRepresentation } from '../util/ResourceUtil'; import { readJsonStream } from '../util/StreamUtil'; @@ -77,8 +78,6 @@ export class IdentityProviderHttpHandler extends OperationHttpHandler { private readonly controls: Record; public constructor(args: IdentityProviderHttpHandlerArgs) { - // It is important that the RequestParser does not read out the Request body stream. - // Otherwise we can't pass it anymore to the OIDC library when needed. super(); // Trimming trailing slashes so the relative URL starts with a slash after slicing this off this.baseUrl = trimTrailingSlashes(joinUrl(args.baseUrl, args.idpPath)); @@ -97,29 +96,19 @@ export class IdentityProviderHttpHandler extends OperationHttpHandler { /** * Finds the matching route and resolves the operation. */ - public async handle({ operation, request, response }: OperationHttpHandlerInput): - Promise { + public async handle({ operation, request, response }: OperationHttpHandlerInput): Promise { // This being defined means we're in an OIDC session let oidcInteraction: Interaction | undefined; try { const provider = await this.providerFactory.getProvider(); - // This being defined means we're in an OIDC session oidcInteraction = await provider.interactionDetails(request, response); } catch { // Just a regular request } - // 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) { - const provider = await this.providerFactory.getProvider(); - this.logger.debug(`Sending request to oidc-provider: ${request.url}`); - // Even though the typings do not indicate this, this is a Promise that needs to be awaited. - // Otherwise the `BaseHttpServerFactory` will write a 404 before the OIDC library could handle the response. - // eslint-disable-next-line @typescript-eslint/await-thenable - await provider.callback(request, response); - return; + throw new NotFoundHttpError(); } // Cloning input data so it can be sent back in case of errors @@ -149,7 +138,7 @@ export class IdentityProviderHttpHandler extends OperationHttpHandler { */ private async findRoute(operation: Operation, oidcInteraction?: Interaction): Promise { if (!operation.target.path.startsWith(this.baseUrl)) { - // This is either an invalid request or a call to the .well-known configuration + // This is an invalid request return; } const pathName = operation.target.path.slice(this.baseUrl.length); diff --git a/src/identity/OidcHttpHandler.ts b/src/identity/OidcHttpHandler.ts new file mode 100644 index 000000000..a8a3fe924 --- /dev/null +++ b/src/identity/OidcHttpHandler.ts @@ -0,0 +1,27 @@ +import { getLoggerFor } from '../logging/LogUtil'; +import type { HttpHandlerInput } from '../server/HttpHandler'; +import { HttpHandler } from '../server/HttpHandler'; +import type { ProviderFactory } from './configuration/ProviderFactory'; + +/** + * HTTP handler that redirects all requests to the OIDC library. + */ +export class OidcHttpHandler extends HttpHandler { + protected readonly logger = getLoggerFor(this); + + private readonly providerFactory: ProviderFactory; + + public constructor(providerFactory: ProviderFactory) { + super(); + this.providerFactory = providerFactory; + } + + public async handle({ request, response }: HttpHandlerInput): Promise { + const provider = await this.providerFactory.getProvider(); + this.logger.debug(`Sending request to oidc-provider: ${request.url}`); + // Even though the typings do not indicate this, this is a Promise that needs to be awaited. + // Otherwise the `BaseHttpServerFactory` will write a 404 before the OIDC library could handle the response. + // eslint-disable-next-line @typescript-eslint/await-thenable + await provider.callback(request, response); + } +} diff --git a/src/identity/configuration/IdentityProviderFactory.ts b/src/identity/configuration/IdentityProviderFactory.ts index 5ef2ff3e1..cec78ee1e 100644 --- a/src/identity/configuration/IdentityProviderFactory.ts +++ b/src/identity/configuration/IdentityProviderFactory.ts @@ -30,7 +30,11 @@ export interface IdentityProviderFactoryArgs { */ baseUrl: string; /** - * Path of the IDP component in the server. + * Path for all requests targeting the OIDC library. + */ + oidcPath: string; + /** + * The entry point for the custom IDP handlers of the server. * Should start with a slash. */ idpPath: string; @@ -62,6 +66,7 @@ export class IdentityProviderFactory implements ProviderFactory { private readonly config: Configuration; private readonly adapterFactory!: AdapterFactory; private readonly baseUrl!: string; + private readonly oidcPath!: string; private readonly idpPath!: string; private readonly storage!: KeyValueStorage; private readonly errorHandler!: ErrorHandler; @@ -107,6 +112,7 @@ export class IdentityProviderFactory implements ProviderFactory { // Allow provider to interpret reverse proxy headers const provider = new Provider(this.baseUrl, config); provider.proxy = true; + return provider; } @@ -210,11 +216,11 @@ export class IdentityProviderFactory implements ProviderFactory { /** * Creates the route string as required by the `oidc-provider` library. - * In case base URL is `http://test.com/foo/`, `idpPath` is `/idp` and `relative` is `device/auth`, + * In case base URL is `http://test.com/foo/`, `oidcPath` is `/idp` and `relative` is `device/auth`, * this would result in `/foo/idp/device/auth`. */ private createRoute(relative: string): string { - return new URL(joinUrl(this.baseUrl, this.idpPath, relative)).pathname; + return new URL(joinUrl(this.baseUrl, this.oidcPath, relative)).pathname; } /** diff --git a/src/index.ts b/src/index.ts index fd5342005..fc5667b28 100644 --- a/src/index.ts +++ b/src/index.ts @@ -165,6 +165,7 @@ export * from './identity/storage/WebIdAdapterFactory'; // Identity export * from './identity/IdentityProviderHttpHandler'; +export * from './identity/OidcHttpHandler'; // Init/Final export * from './init/final/Finalizable'; diff --git a/src/server/AuthorizingHttpHandler.ts b/src/server/AuthorizingHttpHandler.ts index 450d0026a..af8b56aff 100644 --- a/src/server/AuthorizingHttpHandler.ts +++ b/src/server/AuthorizingHttpHandler.ts @@ -58,7 +58,7 @@ export class AuthorizingHttpHandler extends OperationHttpHandler { this.operationHandler = args.operationHandler; } - public async handle(input: OperationHttpHandlerInput): Promise { + public async handle(input: OperationHttpHandlerInput): Promise { const { request, operation } = input; const credentials: CredentialSet = await this.credentialsExtractor.handleSafe(request); this.logger.verbose(`Extracted credentials: ${JSON.stringify(credentials)}`); diff --git a/src/server/OperationHttpHandler.ts b/src/server/OperationHttpHandler.ts index 8f685be35..31b4b1ddc 100644 --- a/src/server/OperationHttpHandler.ts +++ b/src/server/OperationHttpHandler.ts @@ -9,8 +9,6 @@ export interface OperationHttpHandlerInput extends HttpHandlerInput { /** * An HTTP handler that makes use of an already parsed Operation. - * Can either return a ResponseDescription to be resolved by the calling class, - * or undefined if this class handles the response itself. */ export abstract class OperationHttpHandler - extends AsyncHandler {} + extends AsyncHandler {} diff --git a/test/integration/Identity.test.ts b/test/integration/Identity.test.ts index ccd4b925d..36468643c 100644 --- a/test/integration/Identity.test.ts +++ b/test/integration/Identity.test.ts @@ -378,7 +378,7 @@ describe('A Solid server with IDP', (): void => { }); it('should return correct error output.', async(): Promise => { - const res = await fetch(`${baseUrl}idp/auth`); + const res = await fetch(`${baseUrl}.oidc/auth`); expect(res.status).toBe(400); await expect(res.text()).resolves.toContain('InvalidRequest: invalid_request'); }); diff --git a/test/integration/config/server-without-auth.json b/test/integration/config/server-without-auth.json index 963980f47..60a5b38c0 100644 --- a/test/integration/config/server-without-auth.json +++ b/test/integration/config/server-without-auth.json @@ -4,7 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", "files-scs:config/app/setup/disabled.json", - "files-scs:config/http/handler/default.json", + "files-scs:config/http/handler/simple.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", "files-scs:config/http/static/default.json", @@ -26,9 +26,5 @@ "files-scs:config/util/variables/default.json" ], "@graph": [ - { - "@id": "urn:solid-server:default:IdentityProviderHandler", - "@type": "UnsupportedAsyncHandler" - } ] } diff --git a/test/unit/identity/IdentityProviderHttpHandler.test.ts b/test/unit/identity/IdentityProviderHttpHandler.test.ts index d11f8f367..4c246040c 100644 --- a/test/unit/identity/IdentityProviderHttpHandler.test.ts +++ b/test/unit/identity/IdentityProviderHttpHandler.test.ts @@ -17,6 +17,7 @@ import type { RepresentationConverter, RepresentationConverterArgs, } from '../../../src/storage/conversion/RepresentationConverter'; +import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; import { joinUrl } from '../../../src/util/PathUtil'; import { guardedStreamFrom, readableToString } from '../../../src/util/StreamUtil'; import { CONTENT_TYPE, SOLID_HTTP, SOLID_META } from '../../../src/util/Vocabularies'; @@ -46,7 +47,6 @@ describe('An IdentityProviderHttpHandler', (): void => { }; provider = { - callback: jest.fn(), interactionDetails: jest.fn(), } as any; @@ -113,11 +113,9 @@ describe('An IdentityProviderHttpHandler', (): void => { handler = new IdentityProviderHttpHandler(args); }); - it('calls the provider if there is no matching route.', async(): Promise => { + it('throws a 404 if there is no matching route.', async(): Promise => { operation.target.path = joinUrl(baseUrl, 'invalid'); - await expect(handler.handle({ request, response, operation })).resolves.toBeUndefined(); - expect(provider.callback).toHaveBeenCalledTimes(1); - expect(provider.callback).toHaveBeenLastCalledWith(request, response); + await expect(handler.handle({ request, response, operation })).rejects.toThrow(NotFoundHttpError); }); it('creates Representations for InteractionResponseResults.', async(): Promise => { diff --git a/test/unit/identity/OidcHttpHandler.test.ts b/test/unit/identity/OidcHttpHandler.test.ts new file mode 100644 index 000000000..61be21c83 --- /dev/null +++ b/test/unit/identity/OidcHttpHandler.test.ts @@ -0,0 +1,31 @@ +import type { Provider } from 'oidc-provider'; +import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory'; +import { OidcHttpHandler } from '../../../src/identity/OidcHttpHandler'; +import type { HttpRequest } from '../../../src/server/HttpRequest'; +import type { HttpResponse } from '../../../src/server/HttpResponse'; + +describe('An OidcHttpHandler', (): void => { + const request: HttpRequest = {} as any; + const response: HttpResponse = {} as any; + let provider: jest.Mocked; + let providerFactory: jest.Mocked; + let handler: OidcHttpHandler; + + beforeEach(async(): Promise => { + provider = { + callback: jest.fn(), + } as any; + + providerFactory = { + getProvider: jest.fn().mockResolvedValue(provider), + }; + + handler = new OidcHttpHandler(providerFactory); + }); + + it('sends all requests to the OIDC library.', async(): Promise => { + await expect(handler.handle({ request, response })).resolves.toBeUndefined(); + expect(provider.callback).toHaveBeenCalledTimes(1); + expect(provider.callback).toHaveBeenLastCalledWith(request, response); + }); +}); diff --git a/test/unit/identity/configuration/IdentityProviderFactory.test.ts b/test/unit/identity/configuration/IdentityProviderFactory.test.ts index 3c8a0bcb0..6cb3c9024 100644 --- a/test/unit/identity/configuration/IdentityProviderFactory.test.ts +++ b/test/unit/identity/configuration/IdentityProviderFactory.test.ts @@ -12,23 +12,24 @@ jest.mock('oidc-provider', (): any => ({ })); const routes = { - authorization: '/foo/idp/auth', - check_session: '/foo/idp/session/check', - code_verification: '/foo/idp/device', - device_authorization: '/foo/idp/device/auth', - end_session: '/foo/idp/session/end', - introspection: '/foo/idp/token/introspection', - jwks: '/foo/idp/jwks', - pushed_authorization_request: '/foo/idp/request', - registration: '/foo/idp/reg', - revocation: '/foo/idp/token/revocation', - token: '/foo/idp/token', - userinfo: '/foo/idp/me', + authorization: '/foo/oidc/auth', + check_session: '/foo/oidc/session/check', + code_verification: '/foo/oidc/device', + device_authorization: '/foo/oidc/device/auth', + end_session: '/foo/oidc/session/end', + introspection: '/foo/oidc/token/introspection', + jwks: '/foo/oidc/jwks', + pushed_authorization_request: '/foo/oidc/request', + registration: '/foo/oidc/reg', + revocation: '/foo/oidc/token/revocation', + token: '/foo/oidc/token', + userinfo: '/foo/oidc/me', }; describe('An IdentityProviderFactory', (): void => { let baseConfig: Configuration; const baseUrl = 'http://test.com/foo/'; + const oidcPath = '/oidc'; const idpPath = '/idp'; const webId = 'http://alice.test.com/card#me'; let adapterFactory: jest.Mocked; @@ -59,6 +60,7 @@ describe('An IdentityProviderFactory', (): void => { factory = new IdentityProviderFactory(baseConfig, { adapterFactory, baseUrl, + oidcPath, idpPath, storage, errorHandler, @@ -70,6 +72,7 @@ describe('An IdentityProviderFactory', (): void => { expect((): any => new IdentityProviderFactory(baseConfig, { adapterFactory, baseUrl, + oidcPath, idpPath: 'idp', storage, errorHandler, @@ -127,6 +130,7 @@ describe('An IdentityProviderFactory', (): void => { factory = new IdentityProviderFactory(baseConfig, { adapterFactory, baseUrl, + oidcPath, idpPath, storage, errorHandler, @@ -148,6 +152,7 @@ describe('An IdentityProviderFactory', (): void => { const factory2 = new IdentityProviderFactory(baseConfig, { adapterFactory, baseUrl, + oidcPath, idpPath, storage, errorHandler, From 7163a0317b80535ba85e636495cb48b61bb6e6f3 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Fri, 12 Nov 2021 10:10:22 +0100 Subject: [PATCH 11/39] feat: Support redirection through errors --- .../ldp/handler/components/error-handler.json | 17 +++-- .../output/error/RedirectingErrorHandler.ts | 23 +++++++ .../response/RedirectResponseDescription.ts | 9 +-- src/identity/IdentityProviderHttpHandler.ts | 3 +- src/index.ts | 4 ++ src/server/util/RedirectAllHttpHandler.ts | 3 +- src/util/errors/FoundHttpError.ts | 15 +++++ src/util/errors/MovedPermanentlyHttpError.ts | 15 +++++ src/util/errors/RedirectHttpError.ts | 19 ++++++ .../error/RedirectingErrorHandler.test.ts | 25 ++++++++ .../RedirectResponseDescription.test.ts | 17 ++--- .../util/errors/RedirectHttpError.test.ts | 63 +++++++++++++++++++ 12 files changed, 192 insertions(+), 21 deletions(-) create mode 100644 src/http/output/error/RedirectingErrorHandler.ts create mode 100644 src/util/errors/FoundHttpError.ts create mode 100644 src/util/errors/MovedPermanentlyHttpError.ts create mode 100644 src/util/errors/RedirectHttpError.ts create mode 100644 test/unit/http/output/error/RedirectingErrorHandler.test.ts create mode 100644 test/unit/util/errors/RedirectHttpError.test.ts diff --git a/config/ldp/handler/components/error-handler.json b/config/ldp/handler/components/error-handler.json index 406bc88c2..c55579303 100644 --- a/config/ldp/handler/components/error-handler.json +++ b/config/ldp/handler/components/error-handler.json @@ -7,10 +7,19 @@ "@type": "SafeErrorHandler", "showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" }, "errorHandler": { - "comment": "Changes an error into a valid representation to send as a response.", - "@type": "ConvertingErrorHandler", - "converter": { "@id": "urn:solid-server:default:UiEnabledConverter" }, - "showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" } + "@type": "WaterfallHandler", + "handlers": [ + { + "comment": "Internally redirects are created by throwing a specific error, this handler converts them to the correct response.", + "@type": "RedirectingErrorHandler" + }, + { + "comment": "Converts an Error object into a representation for an HTTP response.", + "@type": "ConvertingErrorHandler", + "converter": { "@id": "urn:solid-server:default:UiEnabledConverter" }, + "showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" } + } + ] } } ] diff --git a/src/http/output/error/RedirectingErrorHandler.ts b/src/http/output/error/RedirectingErrorHandler.ts new file mode 100644 index 000000000..8e72d34e0 --- /dev/null +++ b/src/http/output/error/RedirectingErrorHandler.ts @@ -0,0 +1,23 @@ +import { NotImplementedHttpError } from '../../../util/errors/NotImplementedHttpError'; +import { RedirectHttpError } from '../../../util/errors/RedirectHttpError'; +import { RedirectResponseDescription } from '../response/RedirectResponseDescription'; +import type { ResponseDescription } from '../response/ResponseDescription'; +import type { ErrorHandlerArgs } from './ErrorHandler'; +import { ErrorHandler } from './ErrorHandler'; + +/** + * Internally we create redirects by throwing specific {@link RedirectHttpError}s. + * This Error handler converts those to {@link RedirectResponseDescription}s that are used for output. + */ +export class RedirectingErrorHandler extends ErrorHandler { + public async canHandle({ error }: ErrorHandlerArgs): Promise { + if (!RedirectHttpError.isInstance(error)) { + throw new NotImplementedHttpError('Only redirect errors are supported.'); + } + } + + public async handle({ error }: ErrorHandlerArgs): Promise { + // Cast verified by canHandle + return new RedirectResponseDescription(error as RedirectHttpError); + } +} diff --git a/src/http/output/response/RedirectResponseDescription.ts b/src/http/output/response/RedirectResponseDescription.ts index c6a588aba..293fd3596 100644 --- a/src/http/output/response/RedirectResponseDescription.ts +++ b/src/http/output/response/RedirectResponseDescription.ts @@ -1,14 +1,15 @@ import { DataFactory } from 'n3'; +import type { RedirectHttpError } from '../../../util/errors/RedirectHttpError'; import { SOLID_HTTP } from '../../../util/Vocabularies'; import { RepresentationMetadata } from '../../representation/RepresentationMetadata'; import { ResponseDescription } from './ResponseDescription'; /** - * Corresponds to a 301/302 response, containing the relevant location metadata. + * Corresponds to a redirect response, containing the relevant location metadata. */ export class RedirectResponseDescription extends ResponseDescription { - public constructor(location: string, permanently = false) { - const metadata = new RepresentationMetadata({ [SOLID_HTTP.location]: DataFactory.namedNode(location) }); - super(permanently ? 301 : 302, metadata); + public constructor(error: RedirectHttpError) { + const metadata = new RepresentationMetadata({ [SOLID_HTTP.location]: DataFactory.namedNode(error.location) }); + super(error.statusCode, metadata); } } diff --git a/src/identity/IdentityProviderHttpHandler.ts b/src/identity/IdentityProviderHttpHandler.ts index 5be0a772a..e158bc9b4 100644 --- a/src/identity/IdentityProviderHttpHandler.ts +++ b/src/identity/IdentityProviderHttpHandler.ts @@ -10,6 +10,7 @@ import { OperationHttpHandler } from '../server/OperationHttpHandler'; import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter'; import { APPLICATION_JSON } from '../util/ContentTypes'; import { BadRequestHttpError } from '../util/errors/BadRequestHttpError'; +import { FoundHttpError } from '../util/errors/FoundHttpError'; import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; import { joinUrl, trimTrailingSlashes } from '../util/PathUtil'; import { addTemplateMetadata, cloneRepresentation } from '../util/ResourceUtil'; @@ -167,7 +168,7 @@ export class IdentityProviderHttpHandler extends OperationHttpHandler { } // Create a redirect URL with the OIDC library const location = await this.interactionCompleter.handleSafe({ ...result.details, request }); - responseDescription = new RedirectResponseDescription(location); + responseDescription = new RedirectResponseDescription(new FoundHttpError(location)); } else if (result.type === 'error') { // We want to show the errors on the original page in case of html interactions, so we can't just throw them here const preferences = { type: { [APPLICATION_JSON]: 1 }}; diff --git a/src/index.ts b/src/index.ts index fc5667b28..64316e0be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,6 +86,7 @@ export * from './http/ldp/PutOperationHandler'; // HTTP/Output/Error export * from './http/output/error/ConvertingErrorHandler'; export * from './http/output/error/ErrorHandler'; +export * from './http/output/error/RedirectingErrorHandler'; export * from './http/output/error/SafeErrorHandler'; // HTTP/Output/Metadata @@ -320,13 +321,16 @@ export * from './util/errors/BadRequestHttpError'; export * from './util/errors/ConflictHttpError'; export * from './util/errors/ErrorUtil'; export * from './util/errors/ForbiddenHttpError'; +export * from './util/errors/FoundHttpError'; export * from './util/errors/HttpError'; export * from './util/errors/HttpErrorUtil'; export * from './util/errors/InternalServerError'; export * from './util/errors/MethodNotAllowedHttpError'; +export * from './util/errors/MovedPermanentlyHttpError'; export * from './util/errors/NotFoundHttpError'; export * from './util/errors/NotImplementedHttpError'; export * from './util/errors/PreconditionFailedHttpError'; +export * from './util/errors/RedirectHttpError'; export * from './util/errors/SystemError'; export * from './util/errors/UnauthorizedHttpError'; export * from './util/errors/UnsupportedMediaTypeHttpError'; diff --git a/src/server/util/RedirectAllHttpHandler.ts b/src/server/util/RedirectAllHttpHandler.ts index 612eb2522..5a5f5a1a2 100644 --- a/src/server/util/RedirectAllHttpHandler.ts +++ b/src/server/util/RedirectAllHttpHandler.ts @@ -1,6 +1,7 @@ import type { TargetExtractor } from '../../http/input/identifier/TargetExtractor'; import { RedirectResponseDescription } from '../../http/output/response/RedirectResponseDescription'; import type { ResponseWriter } from '../../http/output/ResponseWriter'; +import { FoundHttpError } from '../../util/errors/FoundHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { getRelativeUrl, joinUrl } from '../../util/PathUtil'; import type { HttpHandlerInput } from '../HttpHandler'; @@ -40,7 +41,7 @@ export class RedirectAllHttpHandler extends HttpHandler { } public async handle({ response }: HttpHandlerInput): Promise { - const result = new RedirectResponseDescription(joinUrl(this.baseUrl, this.target)); + const result = new RedirectResponseDescription(new FoundHttpError(joinUrl(this.baseUrl, this.target))); await this.responseWriter.handleSafe({ response, result }); } } diff --git a/src/util/errors/FoundHttpError.ts b/src/util/errors/FoundHttpError.ts new file mode 100644 index 000000000..9e33035d1 --- /dev/null +++ b/src/util/errors/FoundHttpError.ts @@ -0,0 +1,15 @@ +import type { HttpErrorOptions } from './HttpError'; +import { RedirectHttpError } from './RedirectHttpError'; + +/** + * Error used for resources that have been moved temporarily. + */ +export class FoundHttpError extends RedirectHttpError { + public constructor(location: string, message?: string, options?: HttpErrorOptions) { + super(302, location, 'FoundHttpError', message, options); + } + + public static isInstance(error: any): error is FoundHttpError { + return RedirectHttpError.isInstance(error) && error.statusCode === 302; + } +} diff --git a/src/util/errors/MovedPermanentlyHttpError.ts b/src/util/errors/MovedPermanentlyHttpError.ts new file mode 100644 index 000000000..70f88f243 --- /dev/null +++ b/src/util/errors/MovedPermanentlyHttpError.ts @@ -0,0 +1,15 @@ +import type { HttpErrorOptions } from './HttpError'; +import { RedirectHttpError } from './RedirectHttpError'; + +/** + * Error used for resources that have been moved permanently. + */ +export class MovedPermanentlyHttpError extends RedirectHttpError { + public constructor(location: string, message?: string, options?: HttpErrorOptions) { + super(301, location, 'MovedPermanentlyHttpError', message, options); + } + + public static isInstance(error: any): error is MovedPermanentlyHttpError { + return RedirectHttpError.isInstance(error) && error.statusCode === 301; + } +} diff --git a/src/util/errors/RedirectHttpError.ts b/src/util/errors/RedirectHttpError.ts new file mode 100644 index 000000000..d9012b774 --- /dev/null +++ b/src/util/errors/RedirectHttpError.ts @@ -0,0 +1,19 @@ +import type { HttpErrorOptions } from './HttpError'; +import { HttpError } from './HttpError'; + +/** + * Abstract class representing a 3xx redirect. + */ +export abstract class RedirectHttpError extends HttpError { + public readonly location: string; + + protected constructor(statusCode: number, location: string, name: string, message?: string, + options?: HttpErrorOptions) { + super(statusCode, name, message, options); + this.location = location; + } + + public static isInstance(error: any): error is RedirectHttpError { + return HttpError.isInstance(error) && typeof (error as any).location === 'string'; + } +} diff --git a/test/unit/http/output/error/RedirectingErrorHandler.test.ts b/test/unit/http/output/error/RedirectingErrorHandler.test.ts new file mode 100644 index 000000000..1cdd2046b --- /dev/null +++ b/test/unit/http/output/error/RedirectingErrorHandler.test.ts @@ -0,0 +1,25 @@ +import { RedirectingErrorHandler } from '../../../../../src/http/output/error/RedirectingErrorHandler'; +import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; +import { FoundHttpError } from '../../../../../src/util/errors/FoundHttpError'; +import { NotImplementedHttpError } from '../../../../../src/util/errors/NotImplementedHttpError'; +import { SOLID_HTTP } from '../../../../../src/util/Vocabularies'; + +describe('A RedirectingErrorHandler', (): void => { + const preferences = {}; + const handler = new RedirectingErrorHandler(); + + it('only accepts redirect errors.', async(): Promise => { + const unsupportedError = new BadRequestHttpError(); + await expect(handler.canHandle({ error: unsupportedError, preferences })).rejects.toThrow(NotImplementedHttpError); + + const supportedError = new FoundHttpError('http://test.com/foo/bar'); + await expect(handler.canHandle({ error: supportedError, preferences })).resolves.toBeUndefined(); + }); + + it('creates redirect responses.', async(): Promise => { + const error = new FoundHttpError('http://test.com/foo/bar'); + const result = await handler.handle({ error, preferences }); + expect(result.statusCode).toBe(error.statusCode); + expect(result.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(error.location); + }); +}); diff --git a/test/unit/http/output/response/RedirectResponseDescription.test.ts b/test/unit/http/output/response/RedirectResponseDescription.test.ts index 7bbac99fc..2f9096abb 100644 --- a/test/unit/http/output/response/RedirectResponseDescription.test.ts +++ b/test/unit/http/output/response/RedirectResponseDescription.test.ts @@ -1,18 +1,13 @@ import { RedirectResponseDescription } from '../../../../../src/http/output/response/RedirectResponseDescription'; +import { FoundHttpError } from '../../../../../src/util/errors/FoundHttpError'; import { SOLID_HTTP } from '../../../../../src/util/Vocabularies'; describe('A RedirectResponseDescription', (): void => { - const location = 'http://test.com/foo'; + const error = new FoundHttpError('http://test.com/foo'); - it('has status code 302 and a location.', async(): Promise => { - const description = new RedirectResponseDescription(location); - expect(description.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(location); - expect(description.statusCode).toBe(302); - }); - - it('has status code 301 if the change is permanent.', async(): Promise => { - const description = new RedirectResponseDescription(location, true); - expect(description.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(location); - expect(description.statusCode).toBe(301); + it('has status the code and location of the error.', async(): Promise => { + const description = new RedirectResponseDescription(error); + expect(description.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(error.location); + expect(description.statusCode).toBe(error.statusCode); }); }); diff --git a/test/unit/util/errors/RedirectHttpError.test.ts b/test/unit/util/errors/RedirectHttpError.test.ts new file mode 100644 index 000000000..5536c86fc --- /dev/null +++ b/test/unit/util/errors/RedirectHttpError.test.ts @@ -0,0 +1,63 @@ +import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; +import type { HttpErrorOptions } from '../../../../src/util/errors/HttpError'; +import { MovedPermanentlyHttpError } from '../../../../src/util/errors/MovedPermanentlyHttpError'; +import { RedirectHttpError } from '../../../../src/util/errors/RedirectHttpError'; + +class FixedRedirectHttpError extends RedirectHttpError { + public constructor(location: string, message?: string, options?: HttpErrorOptions) { + super(0, location, '', message, options); + } +} + +describe('RedirectHttpError', (): void => { + const errors: [string, number, typeof FixedRedirectHttpError][] = [ + [ 'MovedPermanentlyHttpError', 301, MovedPermanentlyHttpError ], + [ 'FoundHttpError', 302, FoundHttpError ], + ]; + + describe.each(errors)('%s', (name, statusCode, constructor): void => { + const location = 'http://test.com/foo/bar'; + const options = { + cause: new Error('cause'), + errorCode: 'E1234', + details: {}, + }; + const instance = new constructor(location, 'my message', options); + + it(`is an instance of ${name}.`, (): void => { + expect(constructor.isInstance(instance)).toBeTruthy(); + }); + + it(`has name ${name}.`, (): void => { + expect(instance.name).toBe(name); + }); + + it(`has status code ${statusCode}.`, (): void => { + expect(instance.statusCode).toBe(statusCode); + }); + + it('sets the location.', (): void => { + expect(instance.location).toBe(location); + }); + + it('sets the message.', (): void => { + expect(instance.message).toBe('my message'); + }); + + it('sets the cause.', (): void => { + expect(instance.cause).toBe(options.cause); + }); + + it('sets the error code.', (): void => { + expect(instance.errorCode).toBe(options.errorCode); + }); + + it('defaults to an HTTP-specific error code.', (): void => { + expect(new constructor(location).errorCode).toBe(`H${statusCode}`); + }); + + it('sets the details.', (): void => { + expect(instance.details).toBe(options.details); + }); + }); +}); From 4241c5348df880646ac39d34d0f733a0743fcb24 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Fri, 12 Nov 2021 11:46:00 +0100 Subject: [PATCH 12/39] feat: Move redirect support from IDP handler to specific handlers --- config/identity/handler/default.json | 5 -- .../handler/interaction/routes/login.json | 3 +- .../handler/interaction/routes/session.json | 5 +- src/identity/IdentityProviderHttpHandler.ts | 32 ++------- .../CompletingInteractionHandler.ts | 47 ++++++++++++++ .../handler => }/InteractionHandler.ts | 18 ++--- .../interaction/SessionHttpHandler.ts | 22 ++++--- .../handler/ForgotPasswordHandler.ts | 4 +- .../email-password/handler/LoginHandler.ts | 22 ++++--- .../handler/RegistrationHandler.ts | 4 +- .../handler/ResetPasswordHandler.ts | 4 +- .../routing/BasicInteractionRoute.ts | 8 ++- .../interaction/routing/InteractionRoute.ts | 2 +- .../util/BaseInteractionCompleter.ts | 37 +++++++++++ .../interaction/util/InteractionCompleter.ts | 43 ++---------- src/index.ts | 4 +- .../IdentityProviderHttpHandler.test.ts | 41 +----------- .../CompletingInteractionHandler.test.ts | 65 +++++++++++++++++++ .../handler => }/InteractionHandler.test.ts | 8 +-- .../interaction/SessionHttpHandler.test.ts | 42 ++++++++---- .../handler/LoginHandler.test.ts | 57 +++++++++++----- .../routing/BasicInteractionRoute.test.ts | 9 ++- .../util/BaseInteractionCompleter.test.ts | 56 ++++++++++++++++ .../util/InteractionCompleter.test.ts | 58 ----------------- 24 files changed, 350 insertions(+), 246 deletions(-) create mode 100644 src/identity/interaction/CompletingInteractionHandler.ts rename src/identity/interaction/{email-password/handler => }/InteractionHandler.ts (64%) create mode 100644 src/identity/interaction/util/BaseInteractionCompleter.ts create mode 100644 test/unit/identity/interaction/CompletingInteractionHandler.test.ts rename test/unit/identity/interaction/{email-password/handler => }/InteractionHandler.test.ts (70%) create mode 100644 test/unit/identity/interaction/util/BaseInteractionCompleter.test.ts delete mode 100644 test/unit/identity/interaction/util/InteractionCompleter.test.ts diff --git a/config/identity/handler/default.json b/config/identity/handler/default.json index c9b13dc4c..50fe0f71b 100644 --- a/config/identity/handler/default.json +++ b/config/identity/handler/default.json @@ -43,11 +43,6 @@ "args_idpPath": "/idp", "args_providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }, "args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, - "args_interactionCompleter": { - "comment": "Responsible for finishing OIDC interactions.", - "@type": "InteractionCompleter", - "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" } - }, "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" } } ] diff --git a/config/identity/handler/interaction/routes/login.json b/config/identity/handler/interaction/routes/login.json index ecdd6420a..f0fb1b73c 100644 --- a/config/identity/handler/interaction/routes/login.json +++ b/config/identity/handler/interaction/routes/login.json @@ -17,7 +17,8 @@ }, "handler": { "@type": "LoginHandler", - "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" } + "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, + "interactionCompleter": { "@type": "BaseInteractionCompleter" } } } ] diff --git a/config/identity/handler/interaction/routes/session.json b/config/identity/handler/interaction/routes/session.json index c3450a4fb..90910688c 100644 --- a/config/identity/handler/interaction/routes/session.json +++ b/config/identity/handler/interaction/routes/session.json @@ -11,7 +11,10 @@ "BasicInteractionRoute:_viewTemplates_key": "text/html", "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/confirm.html.ejs" }, - "handler": { "@type": "SessionHttpHandler" } + "handler": { + "@type": "SessionHttpHandler", + "interactionCompleter": { "@type": "BaseInteractionCompleter" } + } } ] } diff --git a/src/identity/IdentityProviderHttpHandler.ts b/src/identity/IdentityProviderHttpHandler.ts index e158bc9b4..174d5e68f 100644 --- a/src/identity/IdentityProviderHttpHandler.ts +++ b/src/identity/IdentityProviderHttpHandler.ts @@ -1,26 +1,20 @@ import type { Operation } from '../http/Operation'; import type { ErrorHandler } from '../http/output/error/ErrorHandler'; -import { RedirectResponseDescription } from '../http/output/response/RedirectResponseDescription'; import { ResponseDescription } from '../http/output/response/ResponseDescription'; import { BasicRepresentation } from '../http/representation/BasicRepresentation'; import { getLoggerFor } from '../logging/LogUtil'; -import type { HttpRequest } from '../server/HttpRequest'; import type { OperationHttpHandlerInput } from '../server/OperationHttpHandler'; import { OperationHttpHandler } from '../server/OperationHttpHandler'; import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter'; import { APPLICATION_JSON } from '../util/ContentTypes'; -import { BadRequestHttpError } from '../util/errors/BadRequestHttpError'; -import { FoundHttpError } from '../util/errors/FoundHttpError'; import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; import { joinUrl, trimTrailingSlashes } from '../util/PathUtil'; import { addTemplateMetadata, cloneRepresentation } from '../util/ResourceUtil'; import { readJsonStream } from '../util/StreamUtil'; import type { ProviderFactory } from './configuration/ProviderFactory'; -import type { Interaction } from './interaction/email-password/handler/InteractionHandler'; +import type { Interaction } from './interaction/InteractionHandler'; import type { InteractionRoute, TemplatedInteractionResult } from './interaction/routing/InteractionRoute'; -import type { InteractionCompleter } from './interaction/util/InteractionCompleter'; -// Registration is not standardized within Solid yet, so we use a custom versioned API for now const API_VERSION = '0.2'; export interface IdentityProviderHttpHandlerArgs { @@ -44,10 +38,6 @@ export interface IdentityProviderHttpHandlerArgs { * Used for content negotiation. */ converter: RepresentationConverter; - /** - * Used for POST requests that need to be handled by the OIDC library. - */ - interactionCompleter: InteractionCompleter; /** * Used for converting output errors. */ @@ -73,7 +63,6 @@ export class IdentityProviderHttpHandler extends OperationHttpHandler { private readonly providerFactory: ProviderFactory; private readonly interactionRoutes: InteractionRoute[]; private readonly converter: RepresentationConverter; - private readonly interactionCompleter: InteractionCompleter; private readonly errorHandler: ErrorHandler; private readonly controls: Record; @@ -85,7 +74,6 @@ export class IdentityProviderHttpHandler extends OperationHttpHandler { this.providerFactory = args.providerFactory; this.interactionRoutes = args.interactionRoutes; this.converter = args.converter; - this.interactionCompleter = args.interactionCompleter; this.errorHandler = args.errorHandler; this.controls = Object.assign( @@ -131,7 +119,7 @@ export class IdentityProviderHttpHandler extends OperationHttpHandler { // Reset the body so it can be reused when needed for output operation.body = clone; - return this.handleInteractionResult(operation, request, result, oidcInteraction); + return this.handleInteractionResult(operation, result, oidcInteraction); } /** @@ -155,21 +143,11 @@ export class IdentityProviderHttpHandler extends OperationHttpHandler { * 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: TemplatedInteractionResult, oidcInteraction?: Interaction): Promise { + private async handleInteractionResult(operation: Operation, result: TemplatedInteractionResult, + oidcInteraction?: Interaction): Promise { let responseDescription: ResponseDescription | undefined; - if (result.type === 'complete') { - if (!oidcInteraction) { - throw new BadRequestHttpError( - 'This action can only be performed as part of an OIDC authentication flow.', - { errorCode: 'E0002' }, - ); - } - // Create a redirect URL with the OIDC library - const location = await this.interactionCompleter.handleSafe({ ...result.details, request }); - responseDescription = new RedirectResponseDescription(new FoundHttpError(location)); - } else if (result.type === 'error') { + if (result.type === 'error') { // We want to show the errors on the original page in case of html interactions, so we can't just throw them here const preferences = { type: { [APPLICATION_JSON]: 1 }}; const response = await this.errorHandler.handleSafe({ error: result.error, preferences }); diff --git a/src/identity/interaction/CompletingInteractionHandler.ts b/src/identity/interaction/CompletingInteractionHandler.ts new file mode 100644 index 000000000..5b30f2698 --- /dev/null +++ b/src/identity/interaction/CompletingInteractionHandler.ts @@ -0,0 +1,47 @@ +import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; +import { FoundHttpError } from '../../util/errors/FoundHttpError'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import { InteractionHandler } from './InteractionHandler'; +import type { InteractionCompleterInput, InteractionCompleter } from './util/InteractionCompleter'; + +/** + * Abstract class for {@link InteractionHandler}s that need to call an {@link InteractionCompleter}. + * This is required by handlers that handle IDP behaviour + * and need to complete an OIDC interaction by redirecting back to the client, + * such as when logging in. + * + * Calls the InteractionCompleter with the results returned by the helper function + * and throw a corresponding {@link FoundHttpError}. + */ +export abstract class CompletingInteractionHandler extends InteractionHandler { + protected readonly interactionCompleter: InteractionCompleter; + + protected constructor(interactionCompleter: InteractionCompleter) { + super(); + this.interactionCompleter = interactionCompleter; + } + + public async canHandle(input: InteractionHandlerInput): Promise { + await super.canHandle(input); + if (!input.oidcInteraction) { + throw new BadRequestHttpError( + 'This action can only be performed as part of an OIDC authentication flow.', + { errorCode: 'E0002' }, + ); + } + } + + public async handle(input: InteractionHandlerInput): Promise { + // Interaction is defined due to canHandle call + const parameters = await this.getCompletionParameters(input as Required); + const location = await this.interactionCompleter.handleSafe(parameters); + throw new FoundHttpError(location); + } + + /** + * Generates the parameters necessary to call an InteractionCompleter. + * @param input - The original input parameters to the `handle` function. + */ + protected abstract getCompletionParameters(input: Required): + Promise; +} diff --git a/src/identity/interaction/email-password/handler/InteractionHandler.ts b/src/identity/interaction/InteractionHandler.ts similarity index 64% rename from src/identity/interaction/email-password/handler/InteractionHandler.ts rename to src/identity/interaction/InteractionHandler.ts index d9654e0c4..5bab30adf 100644 --- a/src/identity/interaction/email-password/handler/InteractionHandler.ts +++ b/src/identity/interaction/InteractionHandler.ts @@ -1,12 +1,11 @@ import type { KoaContextWithOIDC } from 'oidc-provider'; -import type { Operation } from '../../../../http/Operation'; -import { APPLICATION_JSON } from '../../../../util/ContentTypes'; -import { NotImplementedHttpError } from '../../../../util/errors/NotImplementedHttpError'; -import { AsyncHandler } from '../../../../util/handlers/AsyncHandler'; -import type { InteractionCompleterParams } from '../../util/InteractionCompleter'; +import type { Operation } from '../../http/Operation'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { AsyncHandler } from '../../util/handlers/AsyncHandler'; // OIDC library does not directly export the Interaction type -export type Interaction = KoaContextWithOIDC['oidc']['entities']['Interaction']; +export type Interaction = NonNullable; export interface InteractionHandlerInput { /** @@ -20,18 +19,13 @@ export interface InteractionHandlerInput { oidcInteraction?: Interaction; } -export type InteractionHandlerResult = InteractionResponseResult | InteractionCompleteResult | InteractionErrorResult; +export type InteractionHandlerResult = InteractionResponseResult | InteractionErrorResult; export interface InteractionResponseResult> { type: 'response'; details?: T; } -export interface InteractionCompleteResult { - type: 'complete'; - details: InteractionCompleterParams; -} - export interface InteractionErrorResult { type: 'error'; error: Error; diff --git a/src/identity/interaction/SessionHttpHandler.ts b/src/identity/interaction/SessionHttpHandler.ts index 3633ba6c9..5e26f88fd 100644 --- a/src/identity/interaction/SessionHttpHandler.ts +++ b/src/identity/interaction/SessionHttpHandler.ts @@ -1,21 +1,25 @@ import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { readJsonStream } from '../../util/StreamUtil'; -import { InteractionHandler } from './email-password/handler/InteractionHandler'; -import type { InteractionCompleteResult, InteractionHandlerInput } from './email-password/handler/InteractionHandler'; +import { CompletingInteractionHandler } from './CompletingInteractionHandler'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import type { InteractionCompleter, InteractionCompleterInput } from './util/InteractionCompleter'; /** * Simple InteractionHttpHandler that sends the session accountId to the InteractionCompleter as webId. + * This is relevant when a client already logged in this session and tries logging in again. */ -export class SessionHttpHandler extends InteractionHandler { - public async handle({ operation, oidcInteraction }: InteractionHandlerInput): Promise { - if (!oidcInteraction?.session) { +export class SessionHttpHandler extends CompletingInteractionHandler { + public constructor(interactionCompleter: InteractionCompleter) { + super(interactionCompleter); + } + + protected async getCompletionParameters({ operation, oidcInteraction }: Required): + Promise { + if (!oidcInteraction.session) { throw new NotImplementedHttpError('Only interactions with a valid session are supported.'); } const { remember } = await readJsonStream(operation.body.data); - return { - type: 'complete', - details: { webId: oidcInteraction.session.accountId, shouldRemember: Boolean(remember) }, - }; + return { oidcInteraction, webId: oidcInteraction.session.accountId, shouldRemember: Boolean(remember) }; } } diff --git a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts index 17e68c99a..7e64f6c50 100644 --- a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts @@ -3,10 +3,10 @@ import { getLoggerFor } from '../../../../logging/LogUtil'; import { ensureTrailingSlash, joinUrl } from '../../../../util/PathUtil'; import { readJsonStream } from '../../../../util/StreamUtil'; import type { TemplateEngine } from '../../../../util/templates/TemplateEngine'; +import { InteractionHandler } from '../../InteractionHandler'; +import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler'; import type { EmailSender } from '../../util/EmailSender'; import type { AccountStore } from '../storage/AccountStore'; -import { InteractionHandler } from './InteractionHandler'; -import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler'; export interface ForgotPasswordHandlerArgs { accountStore: AccountStore; diff --git a/src/identity/interaction/email-password/handler/LoginHandler.ts b/src/identity/interaction/email-password/handler/LoginHandler.ts index 3be6c0a59..2ef26889f 100644 --- a/src/identity/interaction/email-password/handler/LoginHandler.ts +++ b/src/identity/interaction/email-password/handler/LoginHandler.ts @@ -3,24 +3,28 @@ import type { Operation } from '../../../../http/Operation'; import { getLoggerFor } from '../../../../logging/LogUtil'; import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; import { readJsonStream } from '../../../../util/StreamUtil'; +import { CompletingInteractionHandler } from '../../CompletingInteractionHandler'; +import type { InteractionHandlerInput } from '../../InteractionHandler'; +import type { InteractionCompleterInput, InteractionCompleter } from '../../util/InteractionCompleter'; + import type { AccountStore } from '../storage/AccountStore'; -import { InteractionHandler } from './InteractionHandler'; -import type { InteractionCompleteResult, InteractionHandlerInput } from './InteractionHandler'; /** * Handles the submission of the Login Form and logs the user in. + * Will throw a RedirectHttpError on success. */ -export class LoginHandler extends InteractionHandler { +export class LoginHandler extends CompletingInteractionHandler { protected readonly logger = getLoggerFor(this); private readonly accountStore: AccountStore; - public constructor(accountStore: AccountStore) { - super(); + public constructor(accountStore: AccountStore, interactionCompleter: InteractionCompleter) { + super(interactionCompleter); this.accountStore = accountStore; } - public async handle({ operation }: InteractionHandlerInput): Promise { + protected async getCompletionParameters({ operation, oidcInteraction }: Required): + Promise { const { email, password, remember } = await this.parseInput(operation); // Try to log in, will error if email/password combination is invalid const webId = await this.accountStore.authenticate(email, password); @@ -30,10 +34,8 @@ export class LoginHandler extends InteractionHandler { throw new BadRequestHttpError('This server is not an identity provider for this account.'); } this.logger.debug(`Logging in user ${email}`); - return { - type: 'complete', - details: { webId, shouldRemember: remember }, - }; + + return { oidcInteraction, webId, shouldRemember: remember }; } /** diff --git a/src/identity/interaction/email-password/handler/RegistrationHandler.ts b/src/identity/interaction/email-password/handler/RegistrationHandler.ts index e91251606..16db7c395 100644 --- a/src/identity/interaction/email-password/handler/RegistrationHandler.ts +++ b/src/identity/interaction/email-password/handler/RegistrationHandler.ts @@ -1,8 +1,8 @@ import { getLoggerFor } from '../../../../logging/LogUtil'; import { readJsonStream } from '../../../../util/StreamUtil'; +import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler'; +import { InteractionHandler } from '../../InteractionHandler'; import type { RegistrationManager, RegistrationResponse } from '../util/RegistrationManager'; -import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler'; -import { InteractionHandler } from './InteractionHandler'; /** * Supports registration based on the `RegistrationManager` behaviour. diff --git a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts index 07f721a5d..b2150ca20 100644 --- a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts @@ -1,10 +1,10 @@ import assert from 'assert'; import { getLoggerFor } from '../../../../logging/LogUtil'; import { readJsonStream } from '../../../../util/StreamUtil'; +import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler'; +import { InteractionHandler } from '../../InteractionHandler'; import { assertPassword } from '../EmailPasswordUtil'; import type { AccountStore } from '../storage/AccountStore'; -import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler'; -import { InteractionHandler } from './InteractionHandler'; /** * Handles the submission of the ResetPassword form: diff --git a/src/identity/interaction/routing/BasicInteractionRoute.ts b/src/identity/interaction/routing/BasicInteractionRoute.ts index e0a555321..580d3dd12 100644 --- a/src/identity/interaction/routing/BasicInteractionRoute.ts +++ b/src/identity/interaction/routing/BasicInteractionRoute.ts @@ -2,11 +2,12 @@ import type { Operation } from '../../../http/Operation'; import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; import { createErrorMessage, isError } from '../../../util/errors/ErrorUtil'; import { InternalServerError } from '../../../util/errors/InternalServerError'; +import { RedirectHttpError } from '../../../util/errors/RedirectHttpError'; import { trimTrailingSlashes } from '../../../util/PathUtil'; import type { InteractionHandler, Interaction, -} from '../email-password/handler/InteractionHandler'; +} from '../InteractionHandler'; import type { InteractionRoute, TemplatedInteractionResult } from './InteractionRoute'; /** @@ -84,6 +85,11 @@ export class BasicInteractionRoute implements InteractionRoute { const result = await this.handler.handleSafe({ operation, oidcInteraction }); return { ...result, templateFiles: this.responseTemplates }; } catch (err: unknown) { + // Redirect errors need to be propagated and not rendered on the response pages. + // Otherwise, the user would be redirected to a new page only containing that error. + if (RedirectHttpError.isInstance(err)) { + throw err; + } const error = isError(err) ? err : new InternalServerError(createErrorMessage(err)); // Potentially render the error in the view return { type: 'error', error, templateFiles: this.viewTemplates }; diff --git a/src/identity/interaction/routing/InteractionRoute.ts b/src/identity/interaction/routing/InteractionRoute.ts index 92e34bce7..5287ce9b6 100644 --- a/src/identity/interaction/routing/InteractionRoute.ts +++ b/src/identity/interaction/routing/InteractionRoute.ts @@ -1,5 +1,5 @@ import type { Operation } from '../../../http/Operation'; -import type { Interaction, InteractionHandlerResult } from '../email-password/handler/InteractionHandler'; +import type { Interaction, InteractionHandlerResult } from '../InteractionHandler'; export type TemplatedInteractionResult = T & { templateFiles: Record; diff --git a/src/identity/interaction/util/BaseInteractionCompleter.ts b/src/identity/interaction/util/BaseInteractionCompleter.ts new file mode 100644 index 000000000..f70cc24a9 --- /dev/null +++ b/src/identity/interaction/util/BaseInteractionCompleter.ts @@ -0,0 +1,37 @@ +import type { InteractionResults } from 'oidc-provider'; +import type { InteractionCompleterInput } from './InteractionCompleter'; +import { InteractionCompleter } from './InteractionCompleter'; + +/** + * Creates a simple InteractionResults object based on the input parameters and injects it in the Interaction. + */ +export class BaseInteractionCompleter extends InteractionCompleter { + public async handle(input: InteractionCompleterInput): Promise { + const now = Math.floor(Date.now() / 1000); + const result: InteractionResults = { + login: { + account: input.webId, + // Indicates if a persistent cookie should be used instead of a session cookie. + remember: input.shouldRemember, + ts: now, + }, + consent: { + // When OIDC clients want a refresh token, they need to request the 'offline_access' scope. + // This indicates that this scope is not granted to the client in case they do not want to be remembered. + rejectedScopes: input.shouldRemember ? [] : [ 'offline_access' ], + }, + }; + + // Generates the URL a client needs to be redirected to + // after a successful interaction completion (such as logging in). + // Identical behaviour to calling `provider.interactionResult`. + // We use the code below instead of calling that function + // since that function also uses Request/Response objects to generate the Interaction object, + // which we already have here. + const { oidcInteraction } = input; + oidcInteraction.result = { ...oidcInteraction.lastSubmission, ...result }; + await oidcInteraction.save(oidcInteraction.exp - now); + + return oidcInteraction.returnTo; + } +} diff --git a/src/identity/interaction/util/InteractionCompleter.ts b/src/identity/interaction/util/InteractionCompleter.ts index 37938d8d6..20dd4ecef 100644 --- a/src/identity/interaction/util/InteractionCompleter.ts +++ b/src/identity/interaction/util/InteractionCompleter.ts @@ -1,49 +1,16 @@ -import { ServerResponse } from 'http'; -import type { InteractionResults } from 'oidc-provider'; -import type { HttpRequest } from '../../../server/HttpRequest'; import { AsyncHandler } from '../../../util/handlers/AsyncHandler'; -import type { ProviderFactory } from '../../configuration/ProviderFactory'; +import type { Interaction } from '../InteractionHandler'; /** * Parameters required to specify how the interaction should be completed. */ -export interface InteractionCompleterParams { +export interface InteractionCompleterInput { + oidcInteraction: Interaction; webId: string; shouldRemember?: boolean; } -export interface InteractionCompleterInput extends InteractionCompleterParams { - request: HttpRequest; -} - /** - * Completes an IDP interaction, logging the user in. - * Returns the URL the request should be redirected to. + * Class responsible for completing the interaction based on the parameters provided. */ -export class InteractionCompleter extends AsyncHandler { - private readonly providerFactory: ProviderFactory; - - public constructor(providerFactory: ProviderFactory) { - super(); - this.providerFactory = providerFactory; - } - - public async handle(input: InteractionCompleterInput): Promise { - const provider = await this.providerFactory.getProvider(); - const result: InteractionResults = { - login: { - account: input.webId, - remember: input.shouldRemember, - ts: Math.floor(Date.now() / 1000), - }, - consent: { - rejectedScopes: input.shouldRemember ? [] : [ 'offline_access' ], - }, - }; - - // Response object is not actually needed here so we can just mock it like this - // to bypass the OIDC library checks. - // See https://github.com/panva/node-oidc-provider/discussions/1078 - return provider.interactionResult(input.request, Object.create(ServerResponse.prototype), result); - } -} +export abstract class InteractionCompleter extends AsyncHandler {} diff --git a/src/index.ts b/src/index.ts index 64316e0be..8621ee257 100644 --- a/src/index.ts +++ b/src/index.ts @@ -126,7 +126,6 @@ export * from './identity/configuration/IdentityProviderFactory'; export * from './identity/configuration/ProviderFactory'; // Identity/Interaction/Email-Password/Handler -export * from './identity/interaction/email-password/handler/InteractionHandler'; export * from './identity/interaction/email-password/handler/ForgotPasswordHandler'; export * from './identity/interaction/email-password/handler/LoginHandler'; export * from './identity/interaction/email-password/handler/RegistrationHandler'; @@ -148,10 +147,13 @@ export * from './identity/interaction/routing/InteractionRoute'; // Identity/Interaction/Util export * from './identity/interaction/util/BaseEmailSender'; +export * from './identity/interaction/util/BaseInteractionCompleter'; export * from './identity/interaction/util/EmailSender'; export * from './identity/interaction/util/InteractionCompleter'; // Identity/Interaction +export * from './identity/interaction/CompletingInteractionHandler'; +export * from './identity/interaction/InteractionHandler'; export * from './identity/interaction/SessionHttpHandler'; // Identity/Ownership diff --git a/test/unit/identity/IdentityProviderHttpHandler.test.ts b/test/unit/identity/IdentityProviderHttpHandler.test.ts index 4c246040c..6e0deb201 100644 --- a/test/unit/identity/IdentityProviderHttpHandler.test.ts +++ b/test/unit/identity/IdentityProviderHttpHandler.test.ts @@ -9,7 +9,6 @@ import type { ProviderFactory } from '../../../src/identity/configuration/Provid import type { IdentityProviderHttpHandlerArgs } from '../../../src/identity/IdentityProviderHttpHandler'; import { IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler'; import type { InteractionRoute } from '../../../src/identity/interaction/routing/InteractionRoute'; -import type { InteractionCompleter } from '../../../src/identity/interaction/util/InteractionCompleter'; import type { HttpRequest } from '../../../src/server/HttpRequest'; import type { HttpResponse } from '../../../src/server/HttpResponse'; import { getBestPreference } from '../../../src/storage/conversion/ConversionUtil'; @@ -20,7 +19,7 @@ import type { import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; import { joinUrl } from '../../../src/util/PathUtil'; import { guardedStreamFrom, readableToString } from '../../../src/util/StreamUtil'; -import { CONTENT_TYPE, SOLID_HTTP, SOLID_META } from '../../../src/util/Vocabularies'; +import { CONTENT_TYPE, SOLID_META } from '../../../src/util/Vocabularies'; describe('An IdentityProviderHttpHandler', (): void => { const apiVersion = '0.2'; @@ -32,7 +31,6 @@ describe('An IdentityProviderHttpHandler', (): void => { let providerFactory: jest.Mocked; let routes: Record<'response' | 'complete' | 'error', jest.Mocked>; let controls: Record; - let interactionCompleter: jest.Mocked; let converter: jest.Mocked; let errorHandler: jest.Mocked; let provider: jest.Mocked; @@ -94,8 +92,6 @@ describe('An IdentityProviderHttpHandler', (): void => { }), } as any; - interactionCompleter = { handleSafe: jest.fn().mockResolvedValue('http://test.com/idp/auth') } as any; - errorHandler = { handleSafe: jest.fn(({ error }: ErrorHandlerArgs): ResponseDescription => ({ statusCode: 400, data: guardedStreamFrom(`{ "name": "${error.name}", "message": "${error.message}" }`), @@ -107,7 +103,6 @@ describe('An IdentityProviderHttpHandler', (): void => { providerFactory, interactionRoutes: Object.values(routes), converter, - interactionCompleter, errorHandler, }; handler = new IdentityProviderHttpHandler(args); @@ -184,38 +179,4 @@ describe('An IdentityProviderHttpHandler', (): void => { expect(result).toBeDefined(); expect(JSON.parse(await readableToString(result.data!))).toEqual({ apiVersion, authenticating: true, controls }); }); - - it('errors for InteractionCompleteResults if no oidcInteraction is defined.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, '/idp/routeComplete'); - operation.method = 'POST'; - - const error = expect.objectContaining({ - statusCode: 400, - message: 'This action can only be performed as part of an OIDC authentication flow.', - errorCode: 'E0002', - }); - await expect(handler.handle({ request, response, operation })).rejects.toThrow(error); - expect(routes.complete.handleOperation).toHaveBeenCalledTimes(1); - expect(routes.complete.handleOperation).toHaveBeenLastCalledWith(operation, undefined); - expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(0); - }); - - it('calls the interactionCompleter for InteractionCompleteResults and redirects.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, '/idp/routeComplete'); - operation.method = 'POST'; - operation.body = new BasicRepresentation('value', 'text/plain'); - const oidcInteraction = { session: { accountId: 'account' }, prompt: {}} as any; - provider.interactionDetails.mockResolvedValueOnce(oidcInteraction); - const result = (await handler.handle({ request, response, operation }))!; - expect(result).toBeDefined(); - expect(routes.complete.handleOperation).toHaveBeenCalledTimes(1); - expect(routes.complete.handleOperation).toHaveBeenLastCalledWith(operation, oidcInteraction); - expect(operation.body?.metadata.contentType).toBe('application/json'); - - expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); - expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ request, webId: 'webId' }); - const location = await interactionCompleter.handleSafe.mock.results[0].value; - expect(result.statusCode).toBe(302); - expect(result.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(location); - }); }); diff --git a/test/unit/identity/interaction/CompletingInteractionHandler.test.ts b/test/unit/identity/interaction/CompletingInteractionHandler.test.ts new file mode 100644 index 000000000..4fbc4cb69 --- /dev/null +++ b/test/unit/identity/interaction/CompletingInteractionHandler.test.ts @@ -0,0 +1,65 @@ +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import { CompletingInteractionHandler } from '../../../../src/identity/interaction/CompletingInteractionHandler'; +import type { Interaction, InteractionHandlerInput } from '../../../../src/identity/interaction/InteractionHandler'; +import type { + InteractionCompleter, + InteractionCompleterInput, +} from '../../../../src/identity/interaction/util/InteractionCompleter'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; + +const webId = 'http://alice.test.com/card#me'; +class DummyCompletingInteractionHandler extends CompletingInteractionHandler { + public constructor(interactionCompleter: InteractionCompleter) { + super(interactionCompleter); + } + + public async getCompletionParameters(input: Required): Promise { + return { webId, oidcInteraction: input.oidcInteraction }; + } +} + +describe('A CompletingInteractionHandler', (): void => { + const oidcInteraction: Interaction = {} as any; + const location = 'http://test.com/redirect'; + let operation: Operation; + let interactionCompleter: jest.Mocked; + let handler: DummyCompletingInteractionHandler; + + beforeEach(async(): Promise => { + const representation = new BasicRepresentation('', 'application/json'); + operation = { body: representation } as any; + + interactionCompleter = { + handleSafe: jest.fn().mockResolvedValue(location), + } as any; + + handler = new DummyCompletingInteractionHandler(interactionCompleter); + }); + + it('calls the parent JSON canHandle check.', async(): Promise => { + operation.body.metadata.contentType = 'application/x-www-form-urlencoded'; + await expect(handler.canHandle({ operation } as any)).rejects.toThrow(NotImplementedHttpError); + }); + + it('errors if no OidcInteraction is defined.', async(): Promise => { + const error = expect.objectContaining({ + statusCode: 400, + message: 'This action can only be performed as part of an OIDC authentication flow.', + errorCode: 'E0002', + }); + await expect(handler.canHandle({ operation })).rejects.toThrow(error); + + await expect(handler.canHandle({ operation, oidcInteraction })).resolves.toBeUndefined(); + }); + + it('throws a redirect error with the completer location.', async(): Promise => { + const error = expect.objectContaining({ + statusCode: 302, + location, + }); + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(error); + expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); + expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ oidcInteraction, webId }); + }); +}); diff --git a/test/unit/identity/interaction/email-password/handler/InteractionHandler.test.ts b/test/unit/identity/interaction/InteractionHandler.test.ts similarity index 70% rename from test/unit/identity/interaction/email-password/handler/InteractionHandler.test.ts rename to test/unit/identity/interaction/InteractionHandler.test.ts index 4cb14f924..f74cee567 100644 --- a/test/unit/identity/interaction/email-password/handler/InteractionHandler.test.ts +++ b/test/unit/identity/interaction/InteractionHandler.test.ts @@ -1,11 +1,11 @@ -import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import type { InteractionResponseResult, -} from '../../../../../../src/identity/interaction/email-password/handler/InteractionHandler'; +} from '../../../../src/identity/interaction/InteractionHandler'; import { InteractionHandler, -} from '../../../../../../src/identity/interaction/email-password/handler/InteractionHandler'; -import { NotImplementedHttpError } from '../../../../../../src/util/errors/NotImplementedHttpError'; +} from '../../../../src/identity/interaction/InteractionHandler'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; class SimpleInteractionHandler extends InteractionHandler { public async handle(): Promise { diff --git a/test/unit/identity/interaction/SessionHttpHandler.test.ts b/test/unit/identity/interaction/SessionHttpHandler.test.ts index a7bbc34db..ff6206e2b 100644 --- a/test/unit/identity/interaction/SessionHttpHandler.test.ts +++ b/test/unit/identity/interaction/SessionHttpHandler.test.ts @@ -1,31 +1,47 @@ -import type { Interaction } from '../../../../src/identity/interaction/email-password/handler/InteractionHandler'; +import type { InteractionHandlerInput, Interaction } from '../../../../src/identity/interaction/InteractionHandler'; import { SessionHttpHandler } from '../../../../src/identity/interaction/SessionHttpHandler'; +import type { + InteractionCompleter, + InteractionCompleterInput, +} from '../../../../src/identity/interaction/util/InteractionCompleter'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { createPostJsonOperation } from './email-password/handler/Util'; +class PublicSessionHttpHandler extends SessionHttpHandler { + public constructor(interactionCompleter: InteractionCompleter) { + super(interactionCompleter); + } + + public async getCompletionParameters(input: Required): Promise { + return super.getCompletionParameters(input); + } +} + describe('A SessionHttpHandler', (): void => { const webId = 'http://test.com/id#me'; let oidcInteraction: Interaction; - let handler: SessionHttpHandler; + let interactionCompleter: jest.Mocked; + let handler: PublicSessionHttpHandler; beforeEach(async(): Promise => { oidcInteraction = { session: { accountId: webId }} as any; - handler = new SessionHttpHandler(); + interactionCompleter = { + handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'), + } as any; + + handler = new PublicSessionHttpHandler(interactionCompleter); }); - it('requires a defined oidcInteraction with a session.', async(): Promise => { - oidcInteraction!.session = undefined; - await expect(handler.handle({ operation: {} as any, oidcInteraction })).rejects.toThrow(NotImplementedHttpError); - - await expect(handler.handle({ operation: {} as any })).rejects.toThrow(NotImplementedHttpError); + it('requires an oidcInteraction with a defined session.', async(): Promise => { + oidcInteraction.session = undefined; + await expect(handler.getCompletionParameters({ operation: {} as any, oidcInteraction })) + .rejects.toThrow(NotImplementedHttpError); }); - it('returns an InteractionCompleteResult when done.', async(): Promise => { + it('returns the correct completion parameters.', async(): Promise => { const operation = createPostJsonOperation({ remember: true }); - await expect(handler.handle({ operation, oidcInteraction })).resolves.toEqual({ - details: { webId, shouldRemember: true }, - type: 'complete', - }); + await expect(handler.getCompletionParameters({ operation, oidcInteraction })) + .resolves.toEqual({ oidcInteraction, webId, shouldRemember: true }); }); }); diff --git a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts index 7b3642194..456c85917 100644 --- a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts @@ -1,60 +1,81 @@ -import type { - InteractionHandlerInput, -} from '../../../../../../src/identity/interaction/email-password/handler/InteractionHandler'; import { LoginHandler } from '../../../../../../src/identity/interaction/email-password/handler/LoginHandler'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; +import type { + Interaction, + InteractionHandlerInput, +} from '../../../../../../src/identity/interaction/InteractionHandler'; +import type { + InteractionCompleterInput, + InteractionCompleter, +} from '../../../../../../src/identity/interaction/util/InteractionCompleter'; + import { createPostJsonOperation } from './Util'; +class PublicLoginHandler extends LoginHandler { + public constructor(accountStore: AccountStore, interactionCompleter: InteractionCompleter) { + super(accountStore, interactionCompleter); + } + + public async getCompletionParameters(input: Required): Promise { + return super.getCompletionParameters(input); + } +} + describe('A LoginHandler', (): void => { const webId = 'http://alice.test.com/card#me'; const email = 'alice@test.email'; - let input: InteractionHandlerInput; + const oidcInteraction: Interaction = {} as any; + let input: Required; let accountStore: jest.Mocked; - let handler: LoginHandler; + let interactionCompleter: jest.Mocked; + let handler: PublicLoginHandler; beforeEach(async(): Promise => { - input = {} as any; + input = { oidcInteraction } as any; accountStore = { authenticate: jest.fn().mockResolvedValue(webId), getSettings: jest.fn().mockResolvedValue({ useIdp: true }), } as any; - handler = new LoginHandler(accountStore); + interactionCompleter = { + handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'), + } as any; + + handler = new PublicLoginHandler(accountStore, interactionCompleter); }); it('errors on invalid emails.', async(): Promise => { input.operation = createPostJsonOperation({}); - await expect(handler.handle(input)).rejects.toThrow('Email required'); + await expect(handler.getCompletionParameters(input)).rejects.toThrow('Email required'); input.operation = createPostJsonOperation({ email: [ 'a', 'b' ]}); - await expect(handler.handle(input)).rejects.toThrow('Email required'); + await expect(handler.getCompletionParameters(input)).rejects.toThrow('Email required'); }); it('errors on invalid passwords.', async(): Promise => { input.operation = createPostJsonOperation({ email }); - await expect(handler.handle(input)).rejects.toThrow('Password required'); + await expect(handler.getCompletionParameters(input)).rejects.toThrow('Password required'); input.operation = createPostJsonOperation({ email, password: [ 'a', 'b' ]}); - await expect(handler.handle(input)).rejects.toThrow('Password required'); + await expect(handler.getCompletionParameters(input)).rejects.toThrow('Password required'); }); it('throws an error if there is a problem.', async(): Promise => { input.operation = createPostJsonOperation({ email, password: 'password!' }); accountStore.authenticate.mockRejectedValueOnce(new Error('auth failed!')); - await expect(handler.handle(input)).rejects.toThrow('auth failed!'); + await expect(handler.getCompletionParameters(input)).rejects.toThrow('auth failed!'); }); it('throws an error if the account does not have the correct settings.', async(): Promise => { input.operation = createPostJsonOperation({ email, password: 'password!' }); accountStore.getSettings.mockResolvedValueOnce({ useIdp: false }); - await expect(handler.handle(input)).rejects.toThrow('This server is not an identity provider for this account.'); + await expect(handler.getCompletionParameters(input)) + .rejects.toThrow('This server is not an identity provider for this account.'); }); - it('returns an InteractionCompleteResult when done.', async(): Promise => { + it('returns the correct completion parameters.', async(): Promise => { input.operation = createPostJsonOperation({ email, password: 'password!' }); - await expect(handler.handle(input)).resolves.toEqual({ - type: 'complete', - details: { webId, shouldRemember: false }, - }); + await expect(handler.getCompletionParameters(input)) + .resolves.toEqual({ oidcInteraction, webId, shouldRemember: false }); expect(accountStore.authenticate).toHaveBeenCalledTimes(1); expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, 'password!'); }); diff --git a/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts b/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts index ef9d0fb1a..28bda2c1c 100644 --- a/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts +++ b/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts @@ -1,8 +1,9 @@ import type { InteractionHandler, -} from '../../../../../src/identity/interaction/email-password/handler/InteractionHandler'; +} from '../../../../../src/identity/interaction/InteractionHandler'; import { BasicInteractionRoute } from '../../../../../src/identity/interaction/routing/BasicInteractionRoute'; import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; +import { FoundHttpError } from '../../../../../src/util/errors/FoundHttpError'; import { InternalServerError } from '../../../../../src/util/errors/InternalServerError'; describe('A BasicInteractionRoute', (): void => { @@ -57,6 +58,12 @@ describe('A BasicInteractionRoute', (): void => { .resolves.toEqual({ type: 'error', error, templateFiles: viewTemplates }); }); + it('re-throws redirect errors.', async(): Promise => { + const error = new FoundHttpError('http://test.com/redirect'); + handler.handleSafe.mockRejectedValueOnce(error); + await expect(route.handleOperation({ method: 'POST' } as any)).rejects.toThrow(error); + }); + it('creates an internal error in case of non-native errors.', async(): Promise => { handler.handleSafe.mockRejectedValueOnce('notAnError'); await expect(route.handleOperation({ method: 'POST' } as any)).resolves.toEqual({ diff --git a/test/unit/identity/interaction/util/BaseInteractionCompleter.test.ts b/test/unit/identity/interaction/util/BaseInteractionCompleter.test.ts new file mode 100644 index 000000000..4973e6354 --- /dev/null +++ b/test/unit/identity/interaction/util/BaseInteractionCompleter.test.ts @@ -0,0 +1,56 @@ +import type { Interaction } from '../../../../../src/identity/interaction/InteractionHandler'; +import { BaseInteractionCompleter } from '../../../../../src/identity/interaction/util/BaseInteractionCompleter'; + +jest.useFakeTimers(); + +describe('A BaseInteractionCompleter', (): void => { + const now = Math.floor(Date.now() / 1000); + const webId = 'http://alice.test.com/#me'; + let oidcInteraction: jest.Mocked; + let completer: BaseInteractionCompleter; + + beforeEach(async(): Promise => { + oidcInteraction = { + lastSubmission: {}, + exp: now + 500, + returnTo: 'http://test.com/redirect', + save: jest.fn(), + } as any; + + completer = new BaseInteractionCompleter(); + }); + + it('stores the correct data in the interaction.', async(): Promise => { + await expect(completer.handle({ oidcInteraction, webId, shouldRemember: true })) + .resolves.toBe(oidcInteraction.returnTo); + expect(oidcInteraction.result).toEqual({ + login: { + account: webId, + remember: true, + ts: now, + }, + consent: { + rejectedScopes: [], + }, + }); + expect(oidcInteraction.save).toHaveBeenCalledTimes(1); + expect(oidcInteraction.save).toHaveBeenLastCalledWith(500); + }); + + it('rejects offline access if shouldRemember is false.', async(): Promise => { + await expect(completer.handle({ oidcInteraction, webId, shouldRemember: false })) + .resolves.toBe(oidcInteraction.returnTo); + expect(oidcInteraction.result).toEqual({ + login: { + account: webId, + remember: false, + ts: now, + }, + consent: { + rejectedScopes: [ 'offline_access' ], + }, + }); + expect(oidcInteraction.save).toHaveBeenCalledTimes(1); + expect(oidcInteraction.save).toHaveBeenLastCalledWith(500); + }); +}); diff --git a/test/unit/identity/interaction/util/InteractionCompleter.test.ts b/test/unit/identity/interaction/util/InteractionCompleter.test.ts deleted file mode 100644 index 520f5746b..000000000 --- a/test/unit/identity/interaction/util/InteractionCompleter.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { ServerResponse } from 'http'; -import type { Provider } from 'oidc-provider'; -import type { ProviderFactory } from '../../../../../src/identity/configuration/ProviderFactory'; -import { InteractionCompleter } from '../../../../../src/identity/interaction/util/InteractionCompleter'; -import type { HttpRequest } from '../../../../../src/server/HttpRequest'; - -jest.useFakeTimers(); - -describe('An InteractionCompleter', (): void => { - const request: HttpRequest = {} as any; - const webId = 'http://alice.test.com/#me'; - let provider: jest.Mocked; - let completer: InteractionCompleter; - - beforeEach(async(): Promise => { - provider = { - interactionResult: jest.fn(), - } as any; - - const factory: ProviderFactory = { - getProvider: jest.fn().mockResolvedValue(provider), - }; - - completer = new InteractionCompleter(factory); - }); - - it('sends the correct data to the provider.', async(): Promise => { - await expect(completer.handle({ request, webId, shouldRemember: true })) - .resolves.toBeUndefined(); - expect(provider.interactionResult).toHaveBeenCalledTimes(1); - expect(provider.interactionResult).toHaveBeenLastCalledWith(request, expect.any(ServerResponse), { - login: { - account: webId, - remember: true, - ts: Math.floor(Date.now() / 1000), - }, - consent: { - rejectedScopes: [], - }, - }); - }); - - it('rejects offline access if shouldRemember is false.', async(): Promise => { - await expect(completer.handle({ request, webId, shouldRemember: false })) - .resolves.toBeUndefined(); - expect(provider.interactionResult).toHaveBeenCalledTimes(1); - expect(provider.interactionResult).toHaveBeenLastCalledWith(request, expect.any(ServerResponse), { - login: { - account: webId, - remember: false, - ts: Math.floor(Date.now() / 1000), - }, - consent: { - rejectedScopes: [ 'offline_access' ], - }, - }); - }); -}); From 9a1f324685216bd6346fb19e626dcca5145053df Mon Sep 17 00:00:00 2001 From: Lina Date: Thu, 16 Dec 2021 15:09:58 +0100 Subject: [PATCH 13/39] feat: Create VoidLocker to disable locking resources * add: Add VoidLocker and unittest * Update src/util/locking/VoidLocker.ts Co-authored-by: Ruben Verborgh * Update src/util/locking/VoidLocker.ts Co-authored-by: Joachim Van Herwegen * update: noop function and add debug void config * add: debug-void in readme * Update RELEASE_NOTES.md Co-authored-by: Joachim Van Herwegen * Update config/util/README.md Co-authored-by: Joachim Van Herwegen * add: missing line Co-authored-by: lina Co-authored-by: Ruben Verborgh Co-authored-by: Joachim Van Herwegen --- RELEASE_NOTES.md | 1 + config/util/README.md | 1 + config/util/resource-locker/debug-void.json | 13 ++++++++ src/index.ts | 1 + src/util/locking/VoidLocker.ts | 34 +++++++++++++++++++++ test/unit/util/locking/VoidLocker.test.ts | 27 ++++++++++++++++ 6 files changed, 77 insertions(+) create mode 100644 config/util/resource-locker/debug-void.json create mode 100644 src/util/locking/VoidLocker.ts create mode 100644 test/unit/util/locking/VoidLocker.test.ts diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 70bb6acde..505e14ab9 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,6 +3,7 @@ ## v3.0.0 ### New features - The Identity Provider now uses the `webid` scope as required for Solid-OIDC. +- The `VoidLocker` can be used to disable locking for development/testing purposes. This can be enabled by changing the `/config/util/resource-locker/` import to `debug-void.json` ### Configuration changes You might need to make changes to your v2 configuration if you use a custom config. diff --git a/config/util/README.md b/config/util/README.md index 831e18d23..5dc8dc3c2 100644 --- a/config/util/README.md +++ b/config/util/README.md @@ -36,6 +36,7 @@ to the ChainedConverter list. ## Resource-locker Which locking mechanism to use to for example prevent 2 write simultaneous write requests. +* *debug-void*: No locking mechanism, does not prevent simultaneous read/writes. * *memory*: Uses an in-memory locking mechanism. * *redis*: Uses a Redis store for locking. diff --git a/config/util/resource-locker/debug-void.json b/config/util/resource-locker/debug-void.json new file mode 100644 index 000000000..c02f6e129 --- /dev/null +++ b/config/util/resource-locker/debug-void.json @@ -0,0 +1,13 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@graph": [ + { + "comment": [ + "DO NOT USE IN PRODUCTION. ONLY FOR DEVELOPMENT, TESTING, OR DEBUGGING.", + "Allows multiple simultaneous read operations and write operations without locks." + ], + "@id": "urn:solid-server:default:ResourceLocker", + "@type": "VoidLocker" + } + ] +} diff --git a/src/index.ts b/src/index.ts index 8621ee257..6e77d9807 100644 --- a/src/index.ts +++ b/src/index.ts @@ -363,6 +363,7 @@ export * from './util/locking/RedisResourceLocker'; export * from './util/locking/ResourceLocker'; export * from './util/locking/SingleThreadedResourceLocker'; export * from './util/locking/WrappedExpiringReadWriteLocker'; +export * from './util/locking/VoidLocker'; // Util/Templates export * from './util/templates/ChainedTemplateEngine'; diff --git a/src/util/locking/VoidLocker.ts b/src/util/locking/VoidLocker.ts new file mode 100644 index 000000000..bc79f792e --- /dev/null +++ b/src/util/locking/VoidLocker.ts @@ -0,0 +1,34 @@ +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import { getLoggerFor } from '../../logging/LogUtil'; +import type { ExpiringReadWriteLocker } from './ExpiringReadWriteLocker'; + +/** + * This locker will execute the whileLocked function without any locking mechanism + * + * Do not use this locker in combination with storages that doesn't handle concurrent read/writes gracefully + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-function +function noop(): void {} + +export class VoidLocker implements ExpiringReadWriteLocker { + protected readonly logger = getLoggerFor(this); + + public constructor() { + this.logger.warn('Locking mechanism disabled; data integrity during parallel requests not guaranteed.'); + } + + public async withReadLock( + identifier: ResourceIdentifier, + whileLocked: (maintainLock: () => void) => T | Promise, + ): Promise { + return whileLocked(noop); + } + + public async withWriteLock( + identifier: ResourceIdentifier, + whileLocked: (maintainLock: () => void) => T | Promise, + ): Promise { + return whileLocked(noop); + } +} diff --git a/test/unit/util/locking/VoidLocker.test.ts b/test/unit/util/locking/VoidLocker.test.ts new file mode 100644 index 000000000..7bb5c0885 --- /dev/null +++ b/test/unit/util/locking/VoidLocker.test.ts @@ -0,0 +1,27 @@ +import type { ResourceIdentifier } from '../../../../src'; +import { VoidLocker } from '../../../../src/util/locking/VoidLocker'; + +describe('A VoidLocker', (): void => { + it('invokes the whileLocked function immediately with readLock.', async(): Promise => { + const locker = new VoidLocker(); + const identifier: ResourceIdentifier = { path: 'http://test.com/res' }; + const whileLocked = jest.fn().mockImplementation((maintainLockFn: () => void): void => { + maintainLockFn(); + }); + + await locker.withReadLock(identifier, whileLocked); + + expect(whileLocked).toHaveBeenCalledTimes(1); + }); + + it('invokes the whileLocked function immediately with writeLock.', async(): Promise => { + const locker = new VoidLocker(); + const identifier: ResourceIdentifier = { path: 'http://test.com/res' }; + const whileLocked = jest.fn().mockImplementation((maintainLockFn: () => void): void => { + maintainLockFn(); + }); + await locker.withWriteLock(identifier, whileLocked); + + expect(whileLocked).toHaveBeenCalledTimes(1); + }); +}); From 0cb4d7b16114ce9d0d4c5ae0766b4e4e944af9cf Mon Sep 17 00:00:00 2001 From: Arthur Joppart <38424924+BelgianNoise@users.noreply.github.com> Date: Fri, 21 Jan 2022 10:49:05 +0100 Subject: [PATCH 14/39] feat: Add support for quota limits * feat: implemented SizeReporter and FileSizeReporter * test: FileSizeReporter tests * feat: added QuotedDataAccessor * test: added extra test to check recursiveness of filesizereporter * feat: added QuotaStrategy interface * feat: further progress in different files * feat: wrote doc, tests and improved code * feat: fixed bugs and code is now runnable and buildable * feat: finished implementation * fix: revert accidental chanegs * fix: fileSizeReported did not count container size * fix: bug calculating container sizes fixed * test: FileSizeReporter tests * test: QuotaDataValidator tests * test: QuotaError tests * fix: removed console.log * doc: added doc to several files * doc: changed doc for QuotaStrategy to new implementation * fix: improved content length regex * feat: improved GlobalQuotaStrategy code * fix: made FileSizeReported readonly * feat: added comments to quota-file.json * fix: changed default tempFilePath variable * test: included new tempFilePath variable in testing * chore: created seperate command for start:file:quota to pass tests * feat: removed all sync fs calls from FileSizeReporter * feat: minor changes in multple files * fix: changed function signatures to be in line with others * feat: optimized quota data validation * feat: improved FileSizeReporter code * fix: corrected calculation of containersizes and fixed erroring edgecase * feat: save content-length as number in metadata * feat: added comments and changed GlobalQuotaStrategy constructor * feat: changed file names and added small comment * test: AtomicFileDataAccessor tests * test: completed FileSizeReporter tests * fix: content-length is now saved correctly in RepresentationMetadata * feat: adapted content length metadata + tests * fix: removed tempFilePath variable * fix: reverted .gitignore * fix: forgot to remove tempFilePath variable from componentsjs config * test: GlobalQuotaStrategy tests * feat: replaced DataValidator with Validator * feat: reworked DataValidator * feat: added calcultateChunkSize() to SizeReporter * test: updated FileSizeReporter tests * fix: tempFile location now relative to rootFilePath * test: QuotaDataValidator tests * fix: corrected FileSizeReporter tests * fix: adapted FileSizeReporter tests * fix: FileSizeReporter bug on Windows * fix: regex linting error * feat: changed Validator class * feat: added PodQuotaStrategy to enable suota on a per pod basis * chore: bump context versions * fix: Capitalized comments in json file * chore: renamed ValidatorArgs to ValidatorInput * chore: order all exports * fix: made TODO comment clearer * chore: added seperated config files for global and pod based quota + fixed comments * chore: made minor changes to comments * feat: added PassthroughDataAccessor * feat: added PasstroughtDataAccessor + tests * fix: added invalid header check to ContentLengthParser * chore: improved mocks * chore: move quota limit higher up in config * fix: atomicity issue in AtomicFileDataAccessor * chore: moved .internal folder to config from FileSizeReporter * fix: improved algorithm to ignore folders while calculating file size in FileSizeReporter * fix: changes to support containers in the future * fix: added error handling to prevent reading of unexistent files * feat: added generic type to SizeReporter to calculate chunk sizes * test: use mocked DataAccessor * chore: added some comments to test and made minor improvement * fix: fs mock rename * chore: QuotaStrategy.estimateSize refactor * chore: move trackAvailableSpace to abstract class QuotaStrategy * fix: improved test case * test: quota integration tests * chore: edited some comments * chore: change lstat to stat * feat: moved estimateSize to SizeReporter to be consistent with calcultateChunkSize * test: finish up tests to reach coverage * fix: basic config * fix: minor changes to test CI run * fix: small fix for windows * fix: improved writing to file * chore: linting errors * chore: rename trackAvailableSpace * test: improved integration tests * test: logging info for test debugging * test: extra logging for debugging * test: logging for debugging * test: logging for debugging * test: logging for debugging * test: improved Quota integration test setup * test: improve quota tests for CI run * test: debugging Quota test * test: uncommented global quota test * test: changed global quota parameters * test: logging for debugging * test: logging cleanup * chore: minor changes, mostly typo fixes * chore: remove console.log * fix: getting inconsistent results * chore: try fix index.ts CI error * chore: try fix CI error * chore: try fix CI error * chore: revert last commits * chore: fix inconsistent files with origin * test: minor test improvements * chore: minor refactors and improvements * fix: added extra try catch for breaking bug * chore: improve config * chore: minor code improvements * test: use mockFs * feat: add extra check in podQuotaStrategy * chore: replace handle by handleSafe in ValidatingDataAccessor * chore: typo * test: improved Quota integration tests * test: made comment in test more correct * fix: rm -> rmdir for backwards compatibility * fix: fsPromises issue * chore: leave out irrelevant config * chore: removed start script from package.json * fix: Small fixes Co-authored-by: Joachim Van Herwegen --- RELEASE_NOTES.md | 1 + config/ldp/metadata-parser/default.json | 2 + .../parsers/content-length.json | 10 + config/quota-file.json | 48 ++++ config/storage/README.md | 2 + config/storage/backend/global-quota-file.json | 17 ++ config/storage/backend/pod-quota-file.json | 17 ++ .../backend/quota/global-quota-file.json | 13 + .../storage/backend/quota/pod-quota-file.json | 14 ++ config/storage/backend/quota/quota-file.json | 37 +++ .../auxiliary/ComposedAuxiliaryStrategy.ts | 5 +- src/http/auxiliary/RdfValidator.ts | 8 +- src/http/auxiliary/Validator.ts | 8 +- .../input/metadata/ContentLengthParser.ts | 23 ++ .../representation/RepresentationMetadata.ts | 18 +- src/index.ts | 20 +- src/storage/accessors/AtomicDataAccessor.ts | 10 + .../accessors/AtomicFileDataAccessor.ts | 62 +++++ src/storage/accessors/FileDataAccessor.ts | 15 +- .../accessors/PassthroughDataAccessor.ts | 49 ++++ .../accessors/ValidatingDataAccessor.ts | 40 ++++ src/storage/quota/GlobalQuotaStrategy.ts | 19 ++ src/storage/quota/PodQuotaStrategy.ts | 66 ++++++ src/storage/quota/QuotaStrategy.ts | 105 +++++++++ src/storage/size-reporter/FileSizeReporter.ts | 87 +++++++ src/storage/size-reporter/Size.ts | 9 + src/storage/size-reporter/SizeReporter.ts | 44 ++++ src/storage/validators/QuotaValidator.ts | 61 +++++ src/util/Vocabularies.ts | 5 + src/util/errors/PayloadHttpError.ts | 23 ++ test/integration/Quota.test.ts | 222 ++++++++++++++++++ test/integration/config/quota-global.json | 65 +++++ test/integration/config/quota-pod.json | 61 +++++ .../ComposedAuxiliaryStrategy.test.ts | 4 +- test/unit/http/auxiliary/RdfValidator.test.ts | 9 +- .../metadata/ContentLengthParser.test.ts | 32 +++ .../RepresentationMetadata.test.ts | 10 + test/unit/quota/GlobalQuotaStrategy.test.ts | 37 +++ test/unit/quota/PodQuotaStrategy.test.ts | 77 ++++++ test/unit/quota/QuotaStrategy.test.ts | 88 +++++++ .../accessors/AtomicFileDataAccessor.test.ts | 97 ++++++++ .../accessors/PassthroughDataAccessor.test.ts | 80 +++++++ .../accessors/ValidatingDataAccessor.test.ts | 54 +++++ .../size-reporter/FileSizeReporter.test.ts | 132 +++++++++++ .../storage/validators/QuotaValidator.test.ts | 120 ++++++++++ test/unit/util/errors/HttpError.test.ts | 2 + test/util/Util.ts | 19 +- 47 files changed, 1927 insertions(+), 20 deletions(-) create mode 100644 config/ldp/metadata-parser/parsers/content-length.json create mode 100644 config/quota-file.json create mode 100644 config/storage/backend/global-quota-file.json create mode 100644 config/storage/backend/pod-quota-file.json create mode 100644 config/storage/backend/quota/global-quota-file.json create mode 100644 config/storage/backend/quota/pod-quota-file.json create mode 100644 config/storage/backend/quota/quota-file.json create mode 100644 src/http/input/metadata/ContentLengthParser.ts create mode 100644 src/storage/accessors/AtomicDataAccessor.ts create mode 100644 src/storage/accessors/AtomicFileDataAccessor.ts create mode 100644 src/storage/accessors/PassthroughDataAccessor.ts create mode 100644 src/storage/accessors/ValidatingDataAccessor.ts create mode 100644 src/storage/quota/GlobalQuotaStrategy.ts create mode 100644 src/storage/quota/PodQuotaStrategy.ts create mode 100644 src/storage/quota/QuotaStrategy.ts create mode 100644 src/storage/size-reporter/FileSizeReporter.ts create mode 100644 src/storage/size-reporter/Size.ts create mode 100644 src/storage/size-reporter/SizeReporter.ts create mode 100644 src/storage/validators/QuotaValidator.ts create mode 100644 src/util/errors/PayloadHttpError.ts create mode 100644 test/integration/Quota.test.ts create mode 100644 test/integration/config/quota-global.json create mode 100644 test/integration/config/quota-pod.json create mode 100644 test/unit/http/input/metadata/ContentLengthParser.test.ts create mode 100644 test/unit/quota/GlobalQuotaStrategy.test.ts create mode 100644 test/unit/quota/PodQuotaStrategy.test.ts create mode 100644 test/unit/quota/QuotaStrategy.test.ts create mode 100644 test/unit/storage/accessors/AtomicFileDataAccessor.test.ts create mode 100644 test/unit/storage/accessors/PassthroughDataAccessor.test.ts create mode 100644 test/unit/storage/accessors/ValidatingDataAccessor.test.ts create mode 100644 test/unit/storage/size-reporter/FileSizeReporter.test.ts create mode 100644 test/unit/storage/validators/QuotaValidator.test.ts diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 505e14ab9..3b88c3406 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -4,6 +4,7 @@ ### New features - The Identity Provider now uses the `webid` scope as required for Solid-OIDC. - The `VoidLocker` can be used to disable locking for development/testing purposes. This can be enabled by changing the `/config/util/resource-locker/` import to `debug-void.json` +- Added support for setting a quota on the server. See the `config/quota-file.json` config for an example. ### Configuration changes You might need to make changes to your v2 configuration if you use a custom config. diff --git a/config/ldp/metadata-parser/default.json b/config/ldp/metadata-parser/default.json index bd9449c6a..e782bfd4b 100644 --- a/config/ldp/metadata-parser/default.json +++ b/config/ldp/metadata-parser/default.json @@ -2,6 +2,7 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", "import": [ "files-scs:config/ldp/metadata-parser/parsers/content-type.json", + "files-scs:config/ldp/metadata-parser/parsers/content-length.json", "files-scs:config/ldp/metadata-parser/parsers/slug.json", "files-scs:config/ldp/metadata-parser/parsers/link.json" ], @@ -12,6 +13,7 @@ "@type": "ParallelHandler", "handlers": [ { "@id": "urn:solid-server:default:ContentTypeParser" }, + { "@id": "urn:solid-server:default:ContentLengthParser" }, { "@id": "urn:solid-server:default:SlugParser" }, { "@id": "urn:solid-server:default:LinkRelParser" } ] diff --git a/config/ldp/metadata-parser/parsers/content-length.json b/config/ldp/metadata-parser/parsers/content-length.json new file mode 100644 index 000000000..1ec1a2311 --- /dev/null +++ b/config/ldp/metadata-parser/parsers/content-length.json @@ -0,0 +1,10 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Converts content-length headers into RDF metadata.", + "@id": "urn:solid-server:default:ContentLengthParser", + "@type": "ContentLengthParser" + } + ] +} diff --git a/config/quota-file.json b/config/quota-file.json new file mode 100644 index 000000000..d551f8118 --- /dev/null +++ b/config/quota-file.json @@ -0,0 +1,48 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "import": [ + "files-scs:config/app/main/default.json", + "files-scs:config/app/init/default.json", + "files-scs:config/app/setup/required.json", + "files-scs:config/http/handler/default.json", + "files-scs:config/http/middleware/websockets.json", + "files-scs:config/http/server-factory/websockets.json", + "files-scs:config/http/static/default.json", + "files-scs:config/identity/access/public.json", + "files-scs:config/identity/email/default.json", + "files-scs:config/identity/handler/default.json", + "files-scs:config/identity/ownership/token.json", + "files-scs:config/identity/pod/static.json", + "files-scs:config/identity/registration/enabled.json", + "files-scs:config/ldp/authentication/dpop-bearer.json", + "files-scs:config/ldp/authorization/allow-all.json", + "files-scs:config/ldp/handler/default.json", + "files-scs:config/ldp/metadata-parser/default.json", + "files-scs:config/ldp/metadata-writer/default.json", + "files-scs:config/ldp/modes/default.json", + "files-scs:config/storage/backend/pod-quota-file.json", + "files-scs:config/storage/key-value/resource-store.json", + "files-scs:config/storage/middleware/default.json", + "files-scs:config/util/auxiliary/acl.json", + "files-scs:config/util/identifiers/suffix.json", + "files-scs:config/util/index/default.json", + "files-scs:config/util/logging/winston.json", + "files-scs:config/util/representation-conversion/default.json", + "files-scs:config/util/resource-locker/memory.json", + "files-scs:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": "A server that stores its resources on disk while enforcing quota." + }, + { + "@id": "urn:solid-server:default:QuotaStrategy", + "PodQuotaStrategy:_limit_amount": 7000, + "PodQuotaStrategy:_limit_unit": "bytes" + }, + { + "@id": "urn:solid-server:default:SizeReporter", + "FileSizeReporter:_ignoreFolders": [ "^/\\.internal$" ] + } + ] +} diff --git a/config/storage/README.md b/config/storage/README.md index 52e679bfb..9626d2f6a 100644 --- a/config/storage/README.md +++ b/config/storage/README.md @@ -5,7 +5,9 @@ Options related to how data and resources are stored. The final part of the ResourceStore chain that handles data access. * *dynamic*: The routing store used here is needed when using dynamic pod creation. * *file*: Default setup with a file backend. +* *global-quota-file*: File backend with a global quota over the entire server. * *memory*: Default setup with a memory backend. +* *pod-quota-file*: File backend with a max quota per pod. * *regex*: Uses a different backend based on the container that is being used. * *sparql*: Default setup with a SPARQL endpoint backend. Also updates the converting store so all incoming data is transformed into quads. diff --git a/config/storage/backend/global-quota-file.json b/config/storage/backend/global-quota-file.json new file mode 100644 index 000000000..ea40bfc6d --- /dev/null +++ b/config/storage/backend/global-quota-file.json @@ -0,0 +1,17 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "import": [ + "files-scs:config/storage/backend/quota/global-quota-file.json", + "files-scs:config/storage/backend/quota/quota-file.json" + ], + "@graph": [ + { + "comment": "A global quota store setup with a file system backend.", + "@id": "urn:solid-server:default:ResourceStore_Backend", + "@type": "DataAccessorBasedStore", + "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" }, + "auxiliaryStrategy": { "@id": "urn:solid-server:default:AuxiliaryStrategy" }, + "accessor": { "@id": "urn:solid-server:default:FileDataAccessor" } + } + ] +} diff --git a/config/storage/backend/pod-quota-file.json b/config/storage/backend/pod-quota-file.json new file mode 100644 index 000000000..00da72df5 --- /dev/null +++ b/config/storage/backend/pod-quota-file.json @@ -0,0 +1,17 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "import": [ + "files-scs:config/storage/backend/quota/pod-quota-file.json", + "files-scs:config/storage/backend/quota/quota-file.json" + ], + "@graph": [ + { + "comment": "A pod quota store setup with a file system backend.", + "@id": "urn:solid-server:default:ResourceStore_Backend", + "@type": "DataAccessorBasedStore", + "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" }, + "auxiliaryStrategy": { "@id": "urn:solid-server:default:AuxiliaryStrategy" }, + "accessor": { "@id": "urn:solid-server:default:FileDataAccessor" } + } + ] +} diff --git a/config/storage/backend/quota/global-quota-file.json b/config/storage/backend/quota/global-quota-file.json new file mode 100644 index 000000000..d61cd8151 --- /dev/null +++ b/config/storage/backend/quota/global-quota-file.json @@ -0,0 +1,13 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "comment": "Configuration of a GlobalQuotaStrategy to enforce quota globally on the server.", + "@graph": [ + { + "comment": "Enforces quota globally for all data on the server", + "@id": "urn:solid-server:default:QuotaStrategy", + "@type": "GlobalQuotaStrategy", + "reporter": { "@id": "urn:solid-server:default:SizeReporter" }, + "base": { "@id": "urn:solid-server:default:variable:baseUrl" } + } + ] +} diff --git a/config/storage/backend/quota/pod-quota-file.json b/config/storage/backend/quota/pod-quota-file.json new file mode 100644 index 000000000..f36529c60 --- /dev/null +++ b/config/storage/backend/quota/pod-quota-file.json @@ -0,0 +1,14 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "comment": "Configuration of a PodQuotaStrategy to enforce pod quotas on the server.", + "@graph": [ + { + "comment": "Enforces quota for all data per pod on the server", + "@id": "urn:solid-server:default:QuotaStrategy", + "@type": "PodQuotaStrategy", + "reporter": { "@id": "urn:solid-server:default:SizeReporter" }, + "accessor": { "@id": "urn:solid-server:default:AtomicFileDataAccessor" }, + "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" } + } + ] +} diff --git a/config/storage/backend/quota/quota-file.json b/config/storage/backend/quota/quota-file.json new file mode 100644 index 000000000..03516a5e0 --- /dev/null +++ b/config/storage/backend/quota/quota-file.json @@ -0,0 +1,37 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "comment": "DataAccessor configuration using a QuotaStrategy to enforce quota on the server.", + "@graph": [ + { + "comment": "DataAccessor that writes data to the disk with atomicity in mind", + "@id": "urn:solid-server:default:AtomicFileDataAccessor", + "@type": "AtomicFileDataAccessor", + "resourceMapper": { "@id": "urn:solid-server:default:FileIdentifierMapper" }, + "rootFilePath": { "@id": "urn:solid-server:default:variable:rootFilePath" }, + "tempFilePath": "/.internal/tempFiles/" + }, + + { + "comment": "Calculates the space already taken up by a resource", + "@id": "urn:solid-server:default:SizeReporter", + "@type": "FileSizeReporter", + "fileIdentifierMapper": { "@id": "urn:solid-server:default:FileIdentifierMapper" }, + "rootFilePath": { "@id": "urn:solid-server:default:variable:rootFilePath" } + }, + + { + "comment": "Validates the data being written to the server", + "@id": "urn:solid-server:default:QuotaValidator", + "@type": "QuotaValidator", + "strategy": { "@id": "urn:solid-server:default:QuotaStrategy" } + }, + + { + "comment": "Simple wrapper for another DataAccessor but adds validation", + "@id": "urn:solid-server:default:FileDataAccessor", + "@type": "ValidatingDataAccessor", + "accessor": { "@id": "urn:solid-server:default:AtomicFileDataAccessor" }, + "validator": { "@id": "urn:solid-server:default:QuotaValidator" } + } + ] +} diff --git a/src/http/auxiliary/ComposedAuxiliaryStrategy.ts b/src/http/auxiliary/ComposedAuxiliaryStrategy.ts index 25f576b06..8fe47ab3e 100644 --- a/src/http/auxiliary/ComposedAuxiliaryStrategy.ts +++ b/src/http/auxiliary/ComposedAuxiliaryStrategy.ts @@ -58,7 +58,10 @@ export class ComposedAuxiliaryStrategy implements AuxiliaryStrategy { public async validate(representation: Representation): Promise { if (this.validator) { - return this.validator.handleSafe(representation); + await this.validator.handleSafe({ + representation, + identifier: { path: representation.metadata.identifier.value }, + }); } } } diff --git a/src/http/auxiliary/RdfValidator.ts b/src/http/auxiliary/RdfValidator.ts index a9fc56eea..e2a4a72cb 100644 --- a/src/http/auxiliary/RdfValidator.ts +++ b/src/http/auxiliary/RdfValidator.ts @@ -3,6 +3,7 @@ import type { RepresentationConverter } from '../../storage/conversion/Represent import { INTERNAL_QUADS } from '../../util/ContentTypes'; import { cloneRepresentation } from '../../util/ResourceUtil'; import type { Representation } from '../representation/Representation'; +import type { ValidatorInput } from './Validator'; import { Validator } from './Validator'; /** @@ -17,12 +18,11 @@ export class RdfValidator extends Validator { this.converter = converter; } - public async handle(representation: Representation): Promise { + public async handle({ representation, identifier }: ValidatorInput): Promise { // If the data already is quads format we know it's RDF if (representation.metadata.contentType === INTERNAL_QUADS) { - return; + return representation; } - const identifier = { path: representation.metadata.identifier.value }; const preferences = { type: { [INTERNAL_QUADS]: 1 }}; let result; try { @@ -39,5 +39,7 @@ export class RdfValidator extends Validator { } // Drain stream to make sure data was parsed correctly await arrayifyStream(result.data); + + return representation; } } diff --git a/src/http/auxiliary/Validator.ts b/src/http/auxiliary/Validator.ts index 38a83f3d8..974cb4555 100644 --- a/src/http/auxiliary/Validator.ts +++ b/src/http/auxiliary/Validator.ts @@ -1,7 +1,13 @@ import { AsyncHandler } from '../../util/handlers/AsyncHandler'; import type { Representation } from '../representation/Representation'; +import type { ResourceIdentifier } from '../representation/ResourceIdentifier'; + +export type ValidatorInput = { + representation: Representation; + identifier: ResourceIdentifier; +}; /** * Generic interface for classes that validate Representations in some way. */ -export abstract class Validator extends AsyncHandler { } +export abstract class Validator extends AsyncHandler { } diff --git a/src/http/input/metadata/ContentLengthParser.ts b/src/http/input/metadata/ContentLengthParser.ts new file mode 100644 index 000000000..a0cf84954 --- /dev/null +++ b/src/http/input/metadata/ContentLengthParser.ts @@ -0,0 +1,23 @@ +import { getLoggerFor } from '../../../logging/LogUtil'; +import type { HttpRequest } from '../../../server/HttpRequest'; +import type { RepresentationMetadata } from '../../representation/RepresentationMetadata'; +import { MetadataParser } from './MetadataParser'; + +/** + * Parser for the `content-length` header. + */ +export class ContentLengthParser extends MetadataParser { + protected readonly logger = getLoggerFor(this); + + public async handle(input: { request: HttpRequest; metadata: RepresentationMetadata }): Promise { + const contentLength = input.request.headers['content-length']; + if (contentLength) { + const length = /^\s*(\d+)\s*(?:;.*)?$/u.exec(contentLength)?.[1]; + if (length) { + input.metadata.contentLength = Number(length); + } else { + this.logger.warn(`Invalid content-length header found: ${contentLength}.`); + } + } + } +} diff --git a/src/http/representation/RepresentationMetadata.ts b/src/http/representation/RepresentationMetadata.ts index a998c5950..2cb3402a3 100644 --- a/src/http/representation/RepresentationMetadata.ts +++ b/src/http/representation/RepresentationMetadata.ts @@ -2,8 +2,8 @@ import { DataFactory, Store } from 'n3'; import type { BlankNode, DefaultGraph, Literal, NamedNode, Quad, Term } from 'rdf-js'; import { getLoggerFor } from '../../logging/LogUtil'; import { InternalServerError } from '../../util/errors/InternalServerError'; -import { toNamedTerm, toObjectTerm, toCachedNamedNode, isTerm } from '../../util/TermUtil'; -import { CONTENT_TYPE, CONTENT_TYPE_TERM } from '../../util/Vocabularies'; +import { toNamedTerm, toObjectTerm, toCachedNamedNode, isTerm, toLiteral } from '../../util/TermUtil'; +import { CONTENT_TYPE, CONTENT_TYPE_TERM, CONTENT_LENGTH_TERM, XSD } from '../../util/Vocabularies'; import type { ResourceIdentifier } from './ResourceIdentifier'; import { isResourceIdentifier } from './ResourceIdentifier'; @@ -316,4 +316,18 @@ export class RepresentationMetadata { public set contentType(input) { this.set(CONTENT_TYPE_TERM, input); } + + /** + * Shorthand for the CONTENT_LENGTH predicate. + */ + public get contentLength(): number | undefined { + const length = this.get(CONTENT_LENGTH_TERM); + return length?.value ? Number(length.value) : undefined; + } + + public set contentLength(input) { + if (input) { + this.set(CONTENT_LENGTH_TERM, toLiteral(input, XSD.terms.integer)); + } + } } diff --git a/src/index.ts b/src/index.ts index 6e77d9807..964d4e2a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,10 +21,10 @@ export * from './authorization/permissions/MethodModesExtractor'; export * from './authorization/permissions/SparqlPatchModesExtractor'; // Authorization -export * from './authorization/OwnerPermissionReader'; export * from './authorization/AllStaticReader'; export * from './authorization/Authorizer'; export * from './authorization/AuxiliaryReader'; +export * from './authorization/OwnerPermissionReader'; export * from './authorization/PathBasedReader'; export * from './authorization/PermissionBasedAuthorizer'; export * from './authorization/PermissionReader'; @@ -57,6 +57,7 @@ export * from './http/input/identifier/OriginalUrlExtractor'; export * from './http/input/identifier/TargetExtractor'; // HTTP/Input/Metadata +export * from './http/input/metadata/ContentLengthParser'; export * from './http/input/metadata/ContentTypeParser'; export * from './http/input/metadata/LinkRelParser'; export * from './http/input/metadata/MetadataParser'; @@ -248,10 +249,14 @@ export * from './server/util/RedirectAllHttpHandler'; export * from './server/util/RouterHandler'; // Storage/Accessors +export * from './storage/accessors/AtomicDataAccessor'; +export * from './storage/accessors/AtomicFileDataAccessor'; export * from './storage/accessors/DataAccessor'; export * from './storage/accessors/FileDataAccessor'; export * from './storage/accessors/InMemoryDataAccessor'; +export * from './storage/accessors/PassthroughDataAccessor'; export * from './storage/accessors/SparqlDataAccessor'; +export * from './storage/accessors/ValidatingDataAccessor'; // Storage/Conversion export * from './storage/conversion/BaseTypedRepresentationConverter'; @@ -295,6 +300,11 @@ export * from './storage/patch/RepresentationPatcher'; export * from './storage/patch/RepresentationPatchHandler'; export * from './storage/patch/SparqlUpdatePatcher'; +// Storage/Quota +export * from './storage/quota/GlobalQuotaStrategy'; +export * from './storage/quota/PodQuotaStrategy'; +export * from './storage/quota/QuotaStrategy'; + // Storage/Routing export * from './storage/routing/BaseUrlRouterRule'; export * from './storage/routing/ConvertingRouterRule'; @@ -302,6 +312,14 @@ export * from './storage/routing/PreferenceSupport'; export * from './storage/routing/RegexRouterRule'; export * from './storage/routing/RouterRule'; +// Storage/Size-Reporter +export * from './storage/size-reporter/FileSizeReporter'; +export * from './storage/size-reporter/Size'; +export * from './storage/size-reporter/SizeReporter'; + +// Storage/Validators +export * from './storage/validators/QuotaValidator'; + // Storage export * from './storage/AtomicResourceStore'; export * from './storage/BaseResourceStore'; diff --git a/src/storage/accessors/AtomicDataAccessor.ts b/src/storage/accessors/AtomicDataAccessor.ts new file mode 100644 index 000000000..3184167ec --- /dev/null +++ b/src/storage/accessors/AtomicDataAccessor.ts @@ -0,0 +1,10 @@ +import type { DataAccessor } from './DataAccessor'; + +/** + * The AtomicDataAccessor interface has identical function signatures as + * the DataAccessor, with the additional constraint that every function call + * must be atomic in its effect: either the call fully succeeds, reaching the + * desired new state; or it fails, upon which the resulting state remains + * identical to the one before the call. + */ +export interface AtomicDataAccessor extends DataAccessor { } diff --git a/src/storage/accessors/AtomicFileDataAccessor.ts b/src/storage/accessors/AtomicFileDataAccessor.ts new file mode 100644 index 000000000..6eb5f4ac5 --- /dev/null +++ b/src/storage/accessors/AtomicFileDataAccessor.ts @@ -0,0 +1,62 @@ +import { mkdirSync, promises as fsPromises } from 'fs'; +import type { Readable } from 'stream'; +import { v4 } from 'uuid'; +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import type { Guarded } from '../../util/GuardedStream'; +import { joinFilePath } from '../../util/PathUtil'; +import type { FileIdentifierMapper } from '../mapping/FileIdentifierMapper'; +import type { AtomicDataAccessor } from './AtomicDataAccessor'; +import { FileDataAccessor } from './FileDataAccessor'; + +/** + * AtomicDataAccessor that uses the file system to store documents as files and containers as folders. + * Data will first be written to a temporary location and only if no errors occur + * will the data be written to the desired location. + */ +export class AtomicFileDataAccessor extends FileDataAccessor implements AtomicDataAccessor { + private readonly tempFilePath: string; + + public constructor(resourceMapper: FileIdentifierMapper, rootFilePath: string, tempFilePath: string) { + super(resourceMapper); + this.tempFilePath = joinFilePath(rootFilePath, tempFilePath); + // Cannot use fsPromises in constructor + mkdirSync(this.tempFilePath, { recursive: true }); + } + + /** + * Writes the given data as a file (and potential metadata as additional file). + * Data will first be written to a temporary file and if no errors occur only then the + * file will be moved to desired destination. + * If the stream errors it is made sure the temporary file will be deleted. + * The metadata file will only be written if the data was written successfully. + */ + public async writeDocument(identifier: ResourceIdentifier, data: Guarded, metadata: RepresentationMetadata): + Promise { + const link = await this.resourceMapper.mapUrlToFilePath(identifier, false, metadata.contentType); + + // Generate temporary file name + const tempFilePath = joinFilePath(this.tempFilePath, `temp-${v4()}.txt`); + + try { + await this.writeDataFile(tempFilePath, data); + + // Check if we already have a corresponding file with a different extension + await this.verifyExistingExtension(link); + + // When no quota errors occur move the file to its desired location + await fsPromises.rename(tempFilePath, link.filePath); + } catch (error: unknown) { + // Delete the data already written + try { + if ((await this.getStats(tempFilePath)).isFile()) { + await fsPromises.unlink(tempFilePath); + } + } catch { + throw error; + } + throw error; + } + await this.writeMetadata(link, metadata); + } +} diff --git a/src/storage/accessors/FileDataAccessor.ts b/src/storage/accessors/FileDataAccessor.ts index 9efde8d3b..fd6cebc56 100644 --- a/src/storage/accessors/FileDataAccessor.ts +++ b/src/storage/accessors/FileDataAccessor.ts @@ -22,7 +22,7 @@ import type { DataAccessor } from './DataAccessor'; * DataAccessor that uses the file system to store documents as files and containers as folders. */ export class FileDataAccessor implements DataAccessor { - private readonly resourceMapper: FileIdentifierMapper; + protected readonly resourceMapper: FileIdentifierMapper; public constructor(resourceMapper: FileIdentifierMapper) { this.resourceMapper = resourceMapper; @@ -149,7 +149,7 @@ export class FileDataAccessor implements DataAccessor { * @throws NotFoundHttpError * If the file/folder doesn't exist. */ - private async getStats(path: string): Promise { + protected async getStats(path: string): Promise { try { return await fsPromises.stat(path); } catch (error: unknown) { @@ -192,7 +192,7 @@ export class FileDataAccessor implements DataAccessor { * * @returns True if data was written to a file. */ - private async writeMetadata(link: ResourceLink, metadata: RepresentationMetadata): Promise { + protected async writeMetadata(link: ResourceLink, metadata: RepresentationMetadata): Promise { // These are stored by file system conventions metadata.remove(RDF.terms.type, LDP.terms.Resource); metadata.remove(RDF.terms.type, LDP.terms.Container); @@ -327,7 +327,7 @@ export class FileDataAccessor implements DataAccessor { * * @param link - ResourceLink corresponding to the new resource data. */ - private async verifyExistingExtension(link: ResourceLink): Promise { + protected async verifyExistingExtension(link: ResourceLink): Promise { try { // Delete the old file with the (now) wrong extension const oldLink = await this.resourceMapper.mapUrlToFilePath(link.identifier, false); @@ -347,11 +347,14 @@ export class FileDataAccessor implements DataAccessor { * @param path - The filepath of the file to be created. * @param data - The data to be put in the file. */ - private async writeDataFile(path: string, data: Readable): Promise { + protected async writeDataFile(path: string, data: Readable): Promise { return new Promise((resolve, reject): any => { const writeStream = createWriteStream(path); data.pipe(writeStream); - data.on('error', reject); + data.on('error', (error): void => { + reject(error); + writeStream.end(); + }); writeStream.on('error', reject); writeStream.on('finish', resolve); diff --git a/src/storage/accessors/PassthroughDataAccessor.ts b/src/storage/accessors/PassthroughDataAccessor.ts new file mode 100644 index 000000000..1af6eb333 --- /dev/null +++ b/src/storage/accessors/PassthroughDataAccessor.ts @@ -0,0 +1,49 @@ +import type { Readable } from 'stream'; +import type { Representation } from '../../http/representation/Representation'; +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import type { Guarded } from '../../util/GuardedStream'; +import type { AtomicDataAccessor } from './AtomicDataAccessor'; +import type { DataAccessor } from './DataAccessor'; + +/** + * DataAccessor that calls the corresponding functions of the source DataAccessor. + * Can be extended by data accessors that do not want to override all functions + * by implementing a decorator pattern. + */ +export class PassthroughDataAccessor implements DataAccessor { + protected readonly accessor: AtomicDataAccessor; + + public constructor(accessor: DataAccessor) { + this.accessor = accessor; + } + + public async writeDocument(identifier: ResourceIdentifier, data: Guarded, metadata: RepresentationMetadata): + Promise { + return this.accessor.writeDocument(identifier, data, metadata); + } + + public async writeContainer(identifier: ResourceIdentifier, metadata: RepresentationMetadata): Promise { + return this.accessor.writeContainer(identifier, metadata); + } + + public async canHandle(representation: Representation): Promise { + return this.accessor.canHandle(representation); + } + + public async getData(identifier: ResourceIdentifier): Promise> { + return this.accessor.getData(identifier); + } + + public async getMetadata(identifier: ResourceIdentifier): Promise { + return this.accessor.getMetadata(identifier); + } + + public getChildren(identifier: ResourceIdentifier): AsyncIterableIterator { + return this.accessor.getChildren(identifier); + } + + public async deleteResource(identifier: ResourceIdentifier): Promise { + return this.accessor.deleteResource(identifier); + } +} diff --git a/src/storage/accessors/ValidatingDataAccessor.ts b/src/storage/accessors/ValidatingDataAccessor.ts new file mode 100644 index 000000000..394b4c7cb --- /dev/null +++ b/src/storage/accessors/ValidatingDataAccessor.ts @@ -0,0 +1,40 @@ +import type { Readable } from 'stream'; +import type { Validator } from '../../http/auxiliary/Validator'; +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import type { Guarded } from '../../util/GuardedStream'; +import type { DataAccessor } from './DataAccessor'; +import { PassthroughDataAccessor } from './PassthroughDataAccessor'; + +/** + * A ValidatingDataAccessor wraps a DataAccessor such that the data stream is validated while being written. + * An AtomicDataAccessor can be used to prevent data being written in case validation fails. + */ +export class ValidatingDataAccessor extends PassthroughDataAccessor { + private readonly validator: Validator; + + public constructor(accessor: DataAccessor, validator: Validator) { + super(accessor); + this.validator = validator; + } + + public async writeDocument( + identifier: ResourceIdentifier, + data: Guarded, + metadata: RepresentationMetadata, + ): Promise { + const pipedRep = await this.validator.handleSafe({ + representation: new BasicRepresentation(data, metadata), + identifier, + }); + return this.accessor.writeDocument(identifier, pipedRep.data, metadata); + } + + public async writeContainer(identifier: ResourceIdentifier, metadata: RepresentationMetadata): Promise { + // A container's data mainly resides in its metadata, + // of which we can't calculate the disk size of at this point in the code. + // Extra info can be found here: https://github.com/solid/community-server/pull/973#discussion_r723376888 + return this.accessor.writeContainer(identifier, metadata); + } +} diff --git a/src/storage/quota/GlobalQuotaStrategy.ts b/src/storage/quota/GlobalQuotaStrategy.ts new file mode 100644 index 000000000..0800cbb3d --- /dev/null +++ b/src/storage/quota/GlobalQuotaStrategy.ts @@ -0,0 +1,19 @@ +import type { Size } from '../size-reporter/Size'; +import type { SizeReporter } from '../size-reporter/SizeReporter'; +import { QuotaStrategy } from './QuotaStrategy'; + +/** + * The GlobalQuotaStrategy sets a limit on the amount of data stored on the server globally. + */ +export class GlobalQuotaStrategy extends QuotaStrategy { + private readonly base: string; + + public constructor(limit: Size, reporter: SizeReporter, base: string) { + super(reporter, limit); + this.base = base; + } + + protected async getTotalSpaceUsed(): Promise { + return this.reporter.getSize({ path: this.base }); + } +} diff --git a/src/storage/quota/PodQuotaStrategy.ts b/src/storage/quota/PodQuotaStrategy.ts new file mode 100644 index 000000000..803d59501 --- /dev/null +++ b/src/storage/quota/PodQuotaStrategy.ts @@ -0,0 +1,66 @@ +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; +import type { IdentifierStrategy } from '../../util/identifiers/IdentifierStrategy'; +import { RDF, PIM } from '../../util/Vocabularies'; +import type { DataAccessor } from '../accessors/DataAccessor'; +import type { Size } from '../size-reporter/Size'; +import type { SizeReporter } from '../size-reporter/SizeReporter'; +import { QuotaStrategy } from './QuotaStrategy'; + +/** + * The PodQuotaStrategy sets a limit on the amount of data stored on a per pod basis + */ +export class PodQuotaStrategy extends QuotaStrategy { + private readonly identifierStrategy: IdentifierStrategy; + private readonly accessor: DataAccessor; + + public constructor( + limit: Size, + reporter: SizeReporter, + identifierStrategy: IdentifierStrategy, + accessor: DataAccessor, + ) { + super(reporter, limit); + this.identifierStrategy = identifierStrategy; + this.accessor = accessor; + } + + protected async getTotalSpaceUsed(identifier: ResourceIdentifier): Promise { + const pimStorage = await this.searchPimStorage(identifier); + + // No storage was found containing this identifier, so we assume this identifier points to an internal location. + // Quota does not apply here so there is always available space. + if (!pimStorage) { + return { amount: Number.MAX_SAFE_INTEGER, unit: this.limit.unit }; + } + + return this.reporter.getSize(pimStorage); + } + + /** Finds the closest parent container that has pim:storage as metadata */ + private async searchPimStorage(identifier: ResourceIdentifier): Promise { + if (this.identifierStrategy.isRootContainer(identifier)) { + return; + } + + let metadata: RepresentationMetadata; + const parent = this.identifierStrategy.getParentContainer(identifier); + + try { + metadata = await this.accessor.getMetadata(identifier); + } catch (error: unknown) { + if (error instanceof NotFoundHttpError) { + // Resource and/or its metadata do not exist + return this.searchPimStorage(parent); + } + throw error; + } + + const hasPimStorageMetadata = metadata!.getAll(RDF.type) + .some((term): boolean => term.value === PIM.Storage); + + return hasPimStorageMetadata ? identifier : this.searchPimStorage(parent); + } +} + diff --git a/src/storage/quota/QuotaStrategy.ts b/src/storage/quota/QuotaStrategy.ts new file mode 100644 index 000000000..2877cdd24 --- /dev/null +++ b/src/storage/quota/QuotaStrategy.ts @@ -0,0 +1,105 @@ +// These two eslint lines are needed to store 'this' in a variable so it can be used +// in the PassThrough of createQuotaGuard +/* eslint-disable @typescript-eslint/no-this-alias */ +/* eslint-disable consistent-this */ +import { PassThrough } from 'stream'; +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import { PayloadHttpError } from '../../util/errors/PayloadHttpError'; +import type { Guarded } from '../../util/GuardedStream'; +import { guardStream } from '../../util/GuardedStream'; +import type { Size } from '../size-reporter/Size'; +import type { SizeReporter } from '../size-reporter/SizeReporter'; + +/** + * A QuotaStrategy is used when we want to set a limit to the amount of data that can be + * stored on the server. + * This can range from a limit for the whole server to a limit on a per pod basis. + * The way the size of a resource is calculated is implemented by the implementing classes. + * This can be bytes, quads, file count, ... + */ +export abstract class QuotaStrategy { + public readonly reporter: SizeReporter; + public readonly limit: Size; + + public constructor(reporter: SizeReporter, limit: Size) { + this.reporter = reporter; + this.limit = limit; + } + + /** + * Get the available space when writing data to the given identifier. + * If the given resource already exists it will deduct the already taken up + * space by that resource since it is going to be overwritten and thus counts + * as available space. + * + * @param identifier - the identifier of the resource of which you want the available space + * @returns the available space and the unit of the space as a Size object + */ + public async getAvailableSpace(identifier: ResourceIdentifier): Promise { + const totalUsed = await this.getTotalSpaceUsed(identifier); + + // Ignore identifiers where quota does not apply + if (totalUsed.amount === Number.MAX_SAFE_INTEGER) { + return totalUsed; + } + + // When a file is overwritten the space the file takes up right now should also + // be counted as available space as it will disappear/be overwritten + totalUsed.amount -= (await this.reporter.getSize(identifier)).amount; + + return { + amount: this.limit.amount - totalUsed.amount, + unit: this.limit.unit, + }; + } + + /** + * Get the currently used/occupied space. + * + * @param identifier - the identifier that should be used to calculate the total + * @returns a Size object containing the requested value. + * If quota is not relevant for this identifier, Size.amount should be Number.MAX_SAFE_INTEGER + */ + protected abstract getTotalSpaceUsed(identifier: ResourceIdentifier): Promise; + + /** + * Get an estimated size of the resource + * + * @param metadata - the metadata that might include the size + * @returns a Size object containing the estimated size and unit of the resource + */ + public async estimateSize(metadata: RepresentationMetadata): Promise { + const estimate = await this.reporter.estimateSize(metadata); + return estimate ? { unit: this.limit.unit, amount: estimate } : undefined; + } + + /** + * Get a Passthrough stream that will keep track of the available space. + * If the quota is exceeded the stream will emit an error and destroy itself. + * Like other Passthrough instances this will simply pass on the chunks, when the quota isn't exceeded. + * + * @param identifier - the identifier of the resource in question + * @returns a Passthrough instance that errors when quota is exceeded + */ + public async createQuotaGuard(identifier: ResourceIdentifier): Promise> { + let total = 0; + const strategy = this; + const { reporter } = this; + + return guardStream(new PassThrough({ + async transform(this, chunk: any, enc: string, done: () => void): Promise { + total += await reporter.calculateChunkSize(chunk); + const availableSpace = await strategy.getAvailableSpace(identifier); + if (availableSpace.amount < total) { + this.destroy(new PayloadHttpError( + `Quota exceeded by ${total - availableSpace.amount} ${availableSpace.unit} during write`, + )); + } + + this.push(chunk); + done(); + }, + })); + } +} diff --git a/src/storage/size-reporter/FileSizeReporter.ts b/src/storage/size-reporter/FileSizeReporter.ts new file mode 100644 index 000000000..153168677 --- /dev/null +++ b/src/storage/size-reporter/FileSizeReporter.ts @@ -0,0 +1,87 @@ +import type { Stats } from 'fs'; +import { promises as fsPromises } from 'fs'; +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import { joinFilePath, normalizeFilePath, trimTrailingSlashes } from '../../util/PathUtil'; +import type { FileIdentifierMapper } from '../mapping/FileIdentifierMapper'; +import type { Size } from './Size'; +import { UNIT_BYTES } from './Size'; +import type { SizeReporter } from './SizeReporter'; + +/** + * SizeReporter that is used to calculate sizes of resources for a file based system. + */ +export class FileSizeReporter implements SizeReporter { + private readonly fileIdentifierMapper: FileIdentifierMapper; + private readonly ignoreFolders: RegExp[]; + private readonly rootFilePath: string; + + public constructor(fileIdentifierMapper: FileIdentifierMapper, rootFilePath: string, ignoreFolders?: string[]) { + this.fileIdentifierMapper = fileIdentifierMapper; + this.ignoreFolders = ignoreFolders ? ignoreFolders.map((folder: string): RegExp => new RegExp(folder, 'u')) : []; + this.rootFilePath = normalizeFilePath(rootFilePath); + } + + /** The FileSizeReporter will always return data in the form of bytes */ + public getUnit(): string { + return UNIT_BYTES; + } + + /** + * Returns the size of the given resource ( and its children ) in bytes + */ + public async getSize(identifier: ResourceIdentifier): Promise { + const fileLocation = (await this.fileIdentifierMapper.mapUrlToFilePath(identifier, false)).filePath; + + return { unit: this.getUnit(), amount: await this.getTotalSize(fileLocation) }; + } + + public async calculateChunkSize(chunk: string): Promise { + return chunk.length; + } + + /** The estimated size of a resource in this reporter is simply the content-length header */ + public async estimateSize(metadata: RepresentationMetadata): Promise { + return metadata.contentLength; + } + + /** + * Get the total size of a resource and its children if present + * + * @param fileLocation - the resource of which you want the total size of ( on disk ) + * @returns a number specifying how many bytes are used by the resource + */ + private async getTotalSize(fileLocation: string): Promise { + let stat: Stats; + + // Check if the file exists + try { + stat = await fsPromises.stat(fileLocation); + } catch { + return 0; + } + + // If the file's location points to a file, simply return the file's size + if (stat.isFile()) { + return stat.size; + } + + // If the location DOES exist and is NOT a file it should be a directory + // recursively add all sizes of children to the total + const childFiles = await fsPromises.readdir(fileLocation); + const rootFilePathLength = trimTrailingSlashes(this.rootFilePath).length; + + return await childFiles.reduce(async(acc: Promise, current): Promise => { + const childFileLocation = normalizeFilePath(joinFilePath(fileLocation, current)); + let result = await acc; + + // Exclude internal files + if (!this.ignoreFolders.some((folder: RegExp): boolean => + folder.test(childFileLocation.slice(rootFilePathLength)))) { + result += await this.getTotalSize(childFileLocation); + } + + return result; + }, Promise.resolve(stat.size)); + } +} diff --git a/src/storage/size-reporter/Size.ts b/src/storage/size-reporter/Size.ts new file mode 100644 index 000000000..26987179d --- /dev/null +++ b/src/storage/size-reporter/Size.ts @@ -0,0 +1,9 @@ +/** + * Describes the size of something by stating how much of a certain unit is present. + */ +export interface Size { + unit: string; + amount: number; +} + +export const UNIT_BYTES = 'bytes'; diff --git a/src/storage/size-reporter/SizeReporter.ts b/src/storage/size-reporter/SizeReporter.ts new file mode 100644 index 000000000..30ec5d59b --- /dev/null +++ b/src/storage/size-reporter/SizeReporter.ts @@ -0,0 +1,44 @@ +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import type { Size } from './Size'; + +/** + * A SizeReporter's only purpose (at the moment) is to calculate the size + * of a resource. How the size is calculated or what unit it is in is defined by + * the class implementing this interface. + * One might use the amount of bytes and another might use the amount of triples + * stored in a resource. + */ +export interface SizeReporter { + + /** + * Get the unit as a string in which a SizeReporter returns data + */ + getUnit: () => string; + + /** + * Get the size of a given resource + * + * @param identifier - the resource of which you want the size + * @returns The size of the resource as a Size object calculated recursively + * if the identifier leads to a container + */ + getSize: (identifier: ResourceIdentifier) => Promise; + + /** + * Calculate the size of a chunk based on which SizeReporter is being used + * + * @param chunk - the chunk of which you want the size + * @returns the size of the passed chunk as a number + */ + calculateChunkSize: (chunk: T) => Promise; + + /** + * Estimate the size of a body / request by looking at its metadata + * + * @param metadata - the metadata of the resource you want an estimated size of + * @returns the estimated size of the body / request or undefined if no + * meaningful estimation can be made + */ + estimateSize: (metadata: RepresentationMetadata) => Promise; +} diff --git a/src/storage/validators/QuotaValidator.ts b/src/storage/validators/QuotaValidator.ts new file mode 100644 index 000000000..f0993a0ed --- /dev/null +++ b/src/storage/validators/QuotaValidator.ts @@ -0,0 +1,61 @@ +import { Readable, PassThrough } from 'stream'; +import { Validator } from '../../http/auxiliary/Validator'; +import type { ValidatorInput } from '../../http/auxiliary/Validator'; +import type { Representation } from '../../http/representation/Representation'; +import { PayloadHttpError } from '../../util/errors/PayloadHttpError'; +import type { Guarded } from '../../util/GuardedStream'; +import { guardStream } from '../../util/GuardedStream'; +import { pipeSafely } from '../../util/StreamUtil'; +import type { QuotaStrategy } from '../quota/QuotaStrategy'; + +/** + * The QuotaValidator validates data streams by making sure they would not exceed the limits of a QuotaStrategy. + */ +export class QuotaValidator extends Validator { + private readonly strategy: QuotaStrategy; + + public constructor(strategy: QuotaStrategy) { + super(); + this.strategy = strategy; + } + + public async handle({ representation, identifier }: ValidatorInput): Promise { + const { data, metadata } = representation; + + // 1. Get the available size + const availableSize = await this.strategy.getAvailableSpace(identifier); + + // 2. Check if the estimated size is bigger then the available size + const estimatedSize = await this.strategy.estimateSize(metadata); + + if (estimatedSize && availableSize.amount < estimatedSize.amount) { + return { + ...representation, + data: guardStream(new Readable({ + read(this): void { + this.destroy(new PayloadHttpError( + `Quota exceeded: Advertised Content-Length is ${estimatedSize.amount} ${estimatedSize.unit} ` + + `and only ${availableSize.amount} ${availableSize.unit} is available`, + )); + }, + })), + }; + } + + // 3. Track if quota is exceeded during writing + const tracking: Guarded = await this.strategy.createQuotaGuard(identifier); + + // 4. Double check quota is not exceeded after write (concurrent writing possible) + const afterWrite = new PassThrough({ + flush: async(done): Promise => { + const availableSpace = (await this.strategy.getAvailableSpace(identifier)).amount; + done(availableSpace < 0 ? new PayloadHttpError('Quota exceeded after write completed') : undefined); + }, + }); + + return { + ...representation, + data: pipeSafely(pipeSafely(data, tracking), afterWrite), + }; + } +} diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index 060820e22..32384d9b7 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -86,6 +86,10 @@ export const FOAF = createUriAndTermNamespace('http://xmlns.com/foaf/0.1/', 'Agent', ); +export const HH = createUriAndTermNamespace('http://www.w3.org/2011/http-headers#', + 'content-length', +); + export const HTTP = createUriAndTermNamespace('http://www.w3.org/2011/http#', 'statusCodeNumber', ); @@ -155,6 +159,7 @@ export const XSD = createUriAndTermNamespace('http://www.w3.org/2001/XMLSchema#' ); // Alias for commonly used types +export const CONTENT_LENGTH_TERM = HH.terms['content-length']; export const CONTENT_TYPE = MA.format; export const CONTENT_TYPE_TERM = MA.terms.format; export const PREFERRED_PREFIX = VANN.preferredNamespacePrefix; diff --git a/src/util/errors/PayloadHttpError.ts b/src/util/errors/PayloadHttpError.ts new file mode 100644 index 000000000..b8fad8b5f --- /dev/null +++ b/src/util/errors/PayloadHttpError.ts @@ -0,0 +1,23 @@ +import type { HttpErrorOptions } from './HttpError'; +import { HttpError } from './HttpError'; + +/** + * An error thrown when data exceeded the pre configured quota + */ +export class PayloadHttpError extends HttpError { + /** + * Default message is 'Storage quota was exceeded.'. + * @param message - Optional, more specific, message. + * @param options - Optional error options. + */ + public constructor(message?: string, options?: HttpErrorOptions) { + super(413, + 'PayloadHttpError', + message ?? 'Storage quota was exceeded.', + options); + } + + public static isInstance(error: any): error is PayloadHttpError { + return HttpError.isInstance(error) && error.statusCode === 413; + } +} diff --git a/test/integration/Quota.test.ts b/test/integration/Quota.test.ts new file mode 100644 index 000000000..941d72969 --- /dev/null +++ b/test/integration/Quota.test.ts @@ -0,0 +1,222 @@ +import { promises as fsPromises } from 'fs'; +import type { Stats } from 'fs'; +import fetch from 'cross-fetch'; +import type { Response } from 'cross-fetch'; +import { joinFilePath, joinUrl } from '../../src'; +import type { App } from '../../src'; +import { getPort } from '../util/Util'; +import { getDefaultVariables, getTestConfigPath, getTestFolder, instantiateFromConfig, removeFolder } from './Config'; + +/** Performs a simple PUT request to the given 'path' with a body containing 'length' amount of characters */ +async function performSimplePutWithLength(path: string, length: number): Promise { + return fetch( + path, + { + method: 'PUT', + headers: { + 'content-type': 'text/plain', + }, + body: 'A'.repeat(length), + }, + ); +} + +/** Registers two test pods on the server matching the 'baseUrl' */ +async function registerTestPods(baseUrl: string, pods: string[]): Promise { + for (const pod of pods) { + await fetch(`${baseUrl}idp/register/`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + createWebId: 'on', + webId: '', + register: 'on', + createPod: 'on', + podName: pod, + email: `${pod}@example.ai`, + password: 't', + confirmPassword: 't', + submit: '', + }), + }); + } +} + +/* We just want a container with the correct metadata, everything else can be removed */ +async function clearInitialFiles(rootFilePath: string, pods: string[]): Promise { + for (const pod of pods) { + const fileList = await fsPromises.readdir(joinFilePath(rootFilePath, pod)); + for (const file of fileList) { + if (file !== '.meta') { + const path = joinFilePath(rootFilePath, pod, file); + if ((await fsPromises.stat(path)).isDirectory()) { + await fsPromises.rmdir(path, { recursive: true }); + } else { + await fsPromises.unlink(path); + } + } + } + } +} + +describe('A quota server', (): void => { + // The allowed quota depends on what filesystem/OS you are using. + // For example: an empty folder is reported as + // - 0KB on NTFS (most of the time, mileage may vary) + // - 0-...KB on APFS (depending on its contents and settings) + // - 4O96KB on FAT + // This is why we need to determine the size of a folder on the current system. + let folderSizeTest: Stats; + beforeAll(async(): Promise => { + // We want to use an empty folder as on APFS/Mac folder sizes vary a lot + const tempFolder = getTestFolder('quota-temp'); + await fsPromises.mkdir(tempFolder); + folderSizeTest = await fsPromises.stat(tempFolder); + await removeFolder(tempFolder); + }); + const podName1 = 'arthur'; + const podName2 = 'abel'; + + /** Test the general functionality of the server using pod quota */ + describe('with pod quota enabled', (): void => { + const port = getPort('PodQuota'); + const baseUrl = `http://localhost:${port}/`; + const pod1 = joinUrl(baseUrl, podName1); + const pod2 = joinUrl(baseUrl, podName2); + const rootFilePath = getTestFolder('quota-pod'); + + let app: App; + + beforeAll(async(): Promise => { + // Calculate the allowed quota depending on file system used + const size = folderSizeTest.size + 4000; + + const instances = await instantiateFromConfig( + 'urn:solid-server:test:Instances', + getTestConfigPath('quota-pod.json'), + { + ...getDefaultVariables(port, baseUrl), + 'urn:solid-server:default:variable:rootFilePath': rootFilePath, + 'urn:solid-server:default:variable:PodQuota': size, + }, + ) as Record; + ({ app } = instances); + await app.start(); + + // Initialize 2 pods + await registerTestPods(baseUrl, [ podName1, podName2 ]); + await clearInitialFiles(rootFilePath, [ podName1, podName2 ]); + }); + + afterAll(async(): Promise => { + await app.stop(); + await removeFolder(rootFilePath); + }); + + // Test quota in the first pod + it('should return a 413 when the quota is exceeded during write.', async(): Promise => { + const testFile1 = `${pod1}/test1.txt`; + const testFile2 = `${pod1}/test2.txt`; + + const response1 = performSimplePutWithLength(testFile1, 2000); + await expect(response1).resolves.toBeDefined(); + expect((await response1).status).toEqual(201); + + const response2 = performSimplePutWithLength(testFile2, 2500); + await expect(response2).resolves.toBeDefined(); + expect((await response2).status).toEqual(413); + }); + + // Test if writing in another pod is still possible + it('should allow writing in a pod that is not full yet.', async(): Promise => { + const testFile1 = `${pod2}/test1.txt`; + + const response1 = performSimplePutWithLength(testFile1, 2000); + await expect(response1).resolves.toBeDefined(); + expect((await response1).status).toEqual(201); + }); + + // Both pods should not accept this request anymore + it('should block PUT requests to different pods if their quota is exceeded.', async(): Promise => { + const testFile1 = `${pod1}/test2.txt`; + const testFile2 = `${pod2}/test2.txt`; + + const response1 = performSimplePutWithLength(testFile1, 2500); + await expect(response1).resolves.toBeDefined(); + expect((await response1).status).toEqual(413); + + const response2 = performSimplePutWithLength(testFile2, 2500); + await expect(response2).resolves.toBeDefined(); + expect((await response2).status).toEqual(413); + }); + }); + + /** Test the general functionality of the server using global quota */ + describe('with global quota enabled', (): void => { + const port = getPort('GlobalQuota'); + const baseUrl = `http://localhost:${port}/`; + const pod1 = `${baseUrl}${podName1}`; + const pod2 = `${baseUrl}${podName2}`; + const rootFilePath = getTestFolder('quota-global'); + + let app: App; + + beforeAll(async(): Promise => { + // Calculate the allowed quota depending on file system used + const size = (folderSizeTest.size * 3) + 4000; + + const instances = await instantiateFromConfig( + 'urn:solid-server:test:Instances', + getTestConfigPath('quota-global.json'), + { + ...getDefaultVariables(port, baseUrl), + 'urn:solid-server:default:variable:rootFilePath': rootFilePath, + 'urn:solid-server:default:variable:GlobalQuota': size, + }, + ) as Record; + ({ app } = instances); + await app.start(); + + // Initialize 2 pods + await registerTestPods(baseUrl, [ podName1, podName2 ]); + await clearInitialFiles(rootFilePath, [ podName1, podName2 ]); + }); + + afterAll(async(): Promise => { + await app.stop(); + await removeFolder(rootFilePath); + }); + + it('should return 413 when global quota is exceeded.', async(): Promise => { + const testFile1 = `${baseUrl}test1.txt`; + const testFile2 = `${baseUrl}test2.txt`; + + const response1 = performSimplePutWithLength(testFile1, 2000); + await expect(response1).resolves.toBeDefined(); + const awaitedRes1 = await response1; + expect(awaitedRes1.status).toEqual(201); + + const response2 = performSimplePutWithLength(testFile2, 2500); + await expect(response2).resolves.toBeDefined(); + const awaitedRes2 = await response2; + expect(awaitedRes2.status).toEqual(413); + }); + + it('should return 413 when trying to write to any pod when global quota is exceeded.', async(): Promise => { + const testFile1 = `${pod1}/test3.txt`; + const testFile2 = `${pod2}/test4.txt`; + + const response1 = performSimplePutWithLength(testFile1, 2500); + await expect(response1).resolves.toBeDefined(); + const awaitedRes1 = await response1; + expect(awaitedRes1.status).toEqual(413); + + const response2 = performSimplePutWithLength(testFile2, 2500); + await expect(response2).resolves.toBeDefined(); + const awaitedRes2 = await response2; + expect(awaitedRes2.status).toEqual(413); + }); + }); +}); diff --git a/test/integration/config/quota-global.json b/test/integration/config/quota-global.json new file mode 100644 index 000000000..b23a80f38 --- /dev/null +++ b/test/integration/config/quota-global.json @@ -0,0 +1,65 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "import": [ + "files-scs:config/app/main/default.json", + "files-scs:config/app/init/initialize-root.json", + "files-scs:config/app/setup/disabled.json", + "files-scs:config/http/handler/default.json", + "files-scs:config/http/middleware/websockets.json", + "files-scs:config/http/server-factory/websockets.json", + "files-scs:config/http/static/default.json", + "files-scs:config/identity/access/public.json", + "files-scs:config/identity/email/default.json", + "files-scs:config/identity/handler/default.json", + "files-scs:config/identity/ownership/token.json", + "files-scs:config/identity/pod/static.json", + "files-scs:config/identity/registration/enabled.json", + "files-scs:config/ldp/authentication/dpop-bearer.json", + "files-scs:config/ldp/authorization/allow-all.json", + "files-scs:config/ldp/handler/default.json", + "files-scs:config/ldp/metadata-parser/default.json", + "files-scs:config/ldp/metadata-writer/default.json", + "files-scs:config/ldp/modes/default.json", + "files-scs:config/storage/backend/global-quota-file.json", + "files-scs:config/storage/key-value/resource-store.json", + "files-scs:config/storage/middleware/default.json", + "files-scs:config/util/auxiliary/acl.json", + "files-scs:config/util/identifiers/suffix.json", + "files-scs:config/util/index/default.json", + "files-scs:config/util/logging/winston.json", + "files-scs:config/util/representation-conversion/default.json", + "files-scs:config/util/resource-locker/memory.json", + "files-scs:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": "A single-pod server that stores its resources on disk while enforcing quota." + }, + { + "comment": "The set quota enforced globally", + "@id": "urn:solid-server:default:variable:GlobalQuota", + "@type": "Variable" + }, + { + "@id": "urn:solid-server:default:QuotaStrategy", + "GlobalQuotaStrategy:_limit_amount": { + "@id": "urn:solid-server:default:variable:GlobalQuota" + }, + "GlobalQuotaStrategy:_limit_unit": "bytes" + }, + { + "@id": "urn:solid-server:default:SizeReporter", + "FileSizeReporter:_ignoreFolders": [ "^/\\.internal$" ] + }, + { + "@id": "urn:solid-server:test:Instances", + "@type": "RecordObject", + "record": [ + { + "RecordObject:_record_key": "app", + "RecordObject:_record_value": { "@id": "urn:solid-server:default:App" } + } + ] + } + ] +} diff --git a/test/integration/config/quota-pod.json b/test/integration/config/quota-pod.json new file mode 100644 index 000000000..d2497746f --- /dev/null +++ b/test/integration/config/quota-pod.json @@ -0,0 +1,61 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "import": [ + "files-scs:config/app/main/default.json", + "files-scs:config/app/init/initialize-root.json", + "files-scs:config/app/setup/disabled.json", + "files-scs:config/http/handler/default.json", + "files-scs:config/http/middleware/websockets.json", + "files-scs:config/http/server-factory/websockets.json", + "files-scs:config/http/static/default.json", + "files-scs:config/identity/access/public.json", + "files-scs:config/identity/email/default.json", + "files-scs:config/identity/handler/default.json", + "files-scs:config/identity/ownership/token.json", + "files-scs:config/identity/pod/static.json", + "files-scs:config/identity/registration/enabled.json", + "files-scs:config/ldp/authentication/dpop-bearer.json", + "files-scs:config/ldp/authorization/allow-all.json", + "files-scs:config/ldp/handler/default.json", + "files-scs:config/ldp/metadata-parser/default.json", + "files-scs:config/ldp/metadata-writer/default.json", + "files-scs:config/ldp/modes/default.json", + "files-scs:config/storage/backend/pod-quota-file.json", + "files-scs:config/storage/key-value/resource-store.json", + "files-scs:config/storage/middleware/default.json", + "files-scs:config/util/auxiliary/acl.json", + "files-scs:config/util/identifiers/suffix.json", + "files-scs:config/util/index/default.json", + "files-scs:config/util/logging/winston.json", + "files-scs:config/util/representation-conversion/default.json", + "files-scs:config/util/resource-locker/memory.json", + "files-scs:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": "A single-pod server that stores its resources on disk while enforcing quota." + }, + { + "comment": "The set quota enforced per pod", + "@id": "urn:solid-server:default:variable:PodQuota", + "@type": "Variable" + }, + { + "@id": "urn:solid-server:default:QuotaStrategy", + "PodQuotaStrategy:_limit_amount": { + "@id": "urn:solid-server:default:variable:PodQuota" + }, + "PodQuotaStrategy:_limit_unit": "bytes" + }, + { + "@id": "urn:solid-server:test:Instances", + "@type": "RecordObject", + "record": [ + { + "RecordObject:_record_key": "app", + "RecordObject:_record_value": { "@id": "urn:solid-server:default:App" } + } + ] + } + ] +} diff --git a/test/unit/http/auxiliary/ComposedAuxiliaryStrategy.test.ts b/test/unit/http/auxiliary/ComposedAuxiliaryStrategy.test.ts index 4e07aca1c..cdc961c8e 100644 --- a/test/unit/http/auxiliary/ComposedAuxiliaryStrategy.test.ts +++ b/test/unit/http/auxiliary/ComposedAuxiliaryStrategy.test.ts @@ -61,10 +61,10 @@ describe('A ComposedAuxiliaryStrategy', (): void => { }); it('validates data through the Validator.', async(): Promise => { - const representation = { data: 'data!' } as any; + const representation = { data: 'data!', metadata: { identifier: { value: 'any' }}} as any; await expect(strategy.validate(representation)).resolves.toBeUndefined(); expect(validator.handleSafe).toHaveBeenCalledTimes(1); - expect(validator.handleSafe).toHaveBeenLastCalledWith(representation); + expect(validator.handleSafe).toHaveBeenLastCalledWith({ representation, identifier: { path: 'any' }}); }); it('defaults isRequiredInRoot to false.', async(): Promise => { diff --git a/test/unit/http/auxiliary/RdfValidator.test.ts b/test/unit/http/auxiliary/RdfValidator.test.ts index 71b5b48a0..16e3fe8f4 100644 --- a/test/unit/http/auxiliary/RdfValidator.test.ts +++ b/test/unit/http/auxiliary/RdfValidator.test.ts @@ -1,5 +1,6 @@ import { RdfValidator } from '../../../../src/http/auxiliary/RdfValidator'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; import type { RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter'; import { readableToString } from '../../../../src/util/StreamUtil'; import { StaticAsyncHandler } from '../../../util/StaticAsyncHandler'; @@ -8,6 +9,7 @@ import 'jest-rdf'; describe('An RdfValidator', (): void => { let converter: RepresentationConverter; let validator: RdfValidator; + const identifier: ResourceIdentifier = { path: 'any/path' }; beforeEach(async(): Promise => { converter = new StaticAsyncHandler(true, null); @@ -20,14 +22,15 @@ describe('An RdfValidator', (): void => { it('always accepts content-type internal/quads.', async(): Promise => { const representation = new BasicRepresentation('data', 'internal/quads'); - await expect(validator.handle(representation)).resolves.toBeUndefined(); + await expect(validator.handle({ representation, identifier })).resolves.toEqual(representation); }); it('validates data by running it through a converter.', async(): Promise => { converter.handleSafe = jest.fn().mockResolvedValue(new BasicRepresentation('transformedData', 'wrongType')); const representation = new BasicRepresentation('data', 'content-type'); const quads = representation.metadata.quads(); - await expect(validator.handle(representation)).resolves.toBeUndefined(); + // Output is not important for this Validator + await expect(validator.handle({ representation, identifier })).resolves.toBeDefined(); // Make sure the data can still be streamed await expect(readableToString(representation.data)).resolves.toBe('data'); // Make sure the metadata was not changed @@ -37,7 +40,7 @@ describe('An RdfValidator', (): void => { it('throws an error when validating invalid data.', async(): Promise => { converter.handleSafe = jest.fn().mockRejectedValue(new Error('bad data!')); const representation = new BasicRepresentation('data', 'content-type'); - await expect(validator.handle(representation)).rejects.toThrow('bad data!'); + await expect(validator.handle({ representation, identifier })).rejects.toThrow('bad data!'); // Make sure the data on the readable has not been reset expect(representation.data.destroyed).toBe(true); }); diff --git a/test/unit/http/input/metadata/ContentLengthParser.test.ts b/test/unit/http/input/metadata/ContentLengthParser.test.ts new file mode 100644 index 000000000..1805ed9eb --- /dev/null +++ b/test/unit/http/input/metadata/ContentLengthParser.test.ts @@ -0,0 +1,32 @@ +import { ContentLengthParser } from '../../../../../src/http/input/metadata/ContentLengthParser'; +import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata'; +import type { HttpRequest } from '../../../../../src/server/HttpRequest'; + +describe('A ContentLengthParser', (): void => { + const parser = new ContentLengthParser(); + let request: HttpRequest; + let metadata: RepresentationMetadata; + + beforeEach(async(): Promise => { + request = { headers: {}} as HttpRequest; + metadata = new RepresentationMetadata(); + }); + + it('does nothing if there is no content-length header.', async(): Promise => { + await expect(parser.handle({ request, metadata })).resolves.toBeUndefined(); + expect(metadata.quads()).toHaveLength(0); + }); + + it('sets the given content-length as metadata.', async(): Promise => { + request.headers['content-length'] = '50'; + await expect(parser.handle({ request, metadata })).resolves.toBeUndefined(); + expect(metadata.quads()).toHaveLength(1); + expect(metadata.contentLength).toBe(50); + }); + + it('does not set a content-length when the header is invalid.', async(): Promise => { + request.headers['content-length'] = 'aabbcc50ccbbaa'; + await expect(parser.handle({ request, metadata })).resolves.toBeUndefined(); + expect(metadata.quads()).toHaveLength(0); + }); +}); diff --git a/test/unit/http/representation/RepresentationMetadata.test.ts b/test/unit/http/representation/RepresentationMetadata.test.ts index 9ecd27603..b56017752 100644 --- a/test/unit/http/representation/RepresentationMetadata.test.ts +++ b/test/unit/http/representation/RepresentationMetadata.test.ts @@ -60,6 +60,16 @@ describe('A RepresentationMetadata', (): void => { expect(metadata.contentType).toEqual('text/turtle'); }); + it('stores the content-length correctly.', async(): Promise => { + metadata = new RepresentationMetadata(); + metadata.contentLength = 50; + expect(metadata.contentLength).toEqual(50); + + metadata = new RepresentationMetadata(); + metadata.contentLength = undefined; + expect(metadata.contentLength).toBeUndefined(); + }); + it('copies an other metadata object.', async(): Promise => { const other = new RepresentationMetadata({ path: 'otherId' }, { 'test:pred': 'objVal' }); metadata = new RepresentationMetadata(other); diff --git a/test/unit/quota/GlobalQuotaStrategy.test.ts b/test/unit/quota/GlobalQuotaStrategy.test.ts new file mode 100644 index 000000000..650d11e97 --- /dev/null +++ b/test/unit/quota/GlobalQuotaStrategy.test.ts @@ -0,0 +1,37 @@ +import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; +import { GlobalQuotaStrategy } from '../../../src/storage/quota/GlobalQuotaStrategy'; +import { UNIT_BYTES } from '../../../src/storage/size-reporter/Size'; +import type { Size } from '../../../src/storage/size-reporter/Size'; +import type { SizeReporter } from '../../../src/storage/size-reporter/SizeReporter'; + +describe('GlobalQuotaStrategy', (): void => { + let strategy: GlobalQuotaStrategy; + let mockSize: Size; + let mockReporter: jest.Mocked>; + let mockBase: string; + + beforeEach((): void => { + mockSize = { amount: 2000, unit: UNIT_BYTES }; + mockBase = ''; + mockReporter = { + getSize: jest.fn(async(identifier: ResourceIdentifier): Promise => ({ + unit: mockSize.unit, + // This mock will return 1000 as size of the root and 50 for any other resource + amount: identifier.path === mockBase ? 1000 : 50, + })), + getUnit: jest.fn().mockReturnValue(mockSize.unit), + calculateChunkSize: jest.fn(async(chunk: any): Promise => chunk.length), + estimateSize: jest.fn().mockResolvedValue(5), + }; + strategy = new GlobalQuotaStrategy(mockSize, mockReporter, mockBase); + }); + + describe('getAvailableSpace()', (): void => { + it('should return the correct amount of available space left.', async(): Promise => { + const result = strategy.getAvailableSpace({ path: 'any/path' }); + await expect(result).resolves.toEqual( + expect.objectContaining({ amount: mockSize.amount - 950 }), + ); + }); + }); +}); diff --git a/test/unit/quota/PodQuotaStrategy.test.ts b/test/unit/quota/PodQuotaStrategy.test.ts new file mode 100644 index 000000000..64ef455b2 --- /dev/null +++ b/test/unit/quota/PodQuotaStrategy.test.ts @@ -0,0 +1,77 @@ +import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; +import type { DataAccessor } from '../../../src/storage/accessors/DataAccessor'; +import { PodQuotaStrategy } from '../../../src/storage/quota/PodQuotaStrategy'; +import { UNIT_BYTES } from '../../../src/storage/size-reporter/Size'; +import type { Size } from '../../../src/storage/size-reporter/Size'; +import type { SizeReporter } from '../../../src/storage/size-reporter/SizeReporter'; +import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; +import type { IdentifierStrategy } from '../../../src/util/identifiers/IdentifierStrategy'; +import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy'; +import { PIM, RDF } from '../../../src/util/Vocabularies'; +import { mockFs } from '../../util/Util'; + +jest.mock('fs'); + +describe('PodQuotaStrategy', (): void => { + let strategy: PodQuotaStrategy; + let mockSize: Size; + let mockReporter: jest.Mocked>; + let identifierStrategy: IdentifierStrategy; + let accessor: jest.Mocked; + const base = 'http://localhost:3000/'; + const rootFilePath = 'folder'; + + beforeEach((): void => { + jest.restoreAllMocks(); + mockFs(rootFilePath, new Date()); + mockSize = { amount: 2000, unit: UNIT_BYTES }; + identifierStrategy = new SingleRootIdentifierStrategy(base); + mockReporter = { + getSize: jest.fn().mockResolvedValue({ unit: mockSize.unit, amount: 50 }), + getUnit: jest.fn().mockReturnValue(mockSize.unit), + calculateChunkSize: jest.fn(async(chunk: any): Promise => chunk.length), + estimateSize: jest.fn().mockResolvedValue(5), + }; + accessor = { + // Assume that the pod is called "nested" + getMetadata: jest.fn().mockImplementation( + async(identifier: ResourceIdentifier): Promise => { + const res = new RepresentationMetadata(); + if (identifier.path === `${base}nested/`) { + res.add(RDF.type, PIM.Storage); + } + return res; + }, + ), + } as any; + strategy = new PodQuotaStrategy(mockSize, mockReporter, identifierStrategy, accessor); + }); + + describe('getAvailableSpace()', (): void => { + it('should return a Size containing MAX_SAFE_INTEGER when writing outside a pod.', async(): Promise => { + const result = strategy.getAvailableSpace({ path: `${base}file.txt` }); + await expect(result).resolves.toEqual(expect.objectContaining({ amount: Number.MAX_SAFE_INTEGER })); + }); + it('should ignore the size of the existing resource when writing inside a pod.', async(): Promise => { + const result = strategy.getAvailableSpace({ path: `${base}nested/nested2/file.txt` }); + await expect(result).resolves.toEqual(expect.objectContaining({ amount: mockSize.amount })); + expect(mockReporter.getSize).toHaveBeenCalledTimes(2); + }); + it('should return a Size containing the available space when writing inside a pod.', async(): Promise => { + accessor.getMetadata.mockImplementationOnce((): any => { + throw new NotFoundHttpError(); + }); + const result = strategy.getAvailableSpace({ path: `${base}nested/nested2/file.txt` }); + await expect(result).resolves.toEqual(expect.objectContaining({ amount: mockSize.amount })); + expect(mockReporter.getSize).toHaveBeenCalledTimes(2); + }); + it('should throw when looking for pim:Storage errors.', async(): Promise => { + accessor.getMetadata.mockImplementationOnce((): any => { + throw new Error('error'); + }); + const result = strategy.getAvailableSpace({ path: `${base}nested/nested2/file.txt` }); + await expect(result).rejects.toThrow('error'); + }); + }); +}); diff --git a/test/unit/quota/QuotaStrategy.test.ts b/test/unit/quota/QuotaStrategy.test.ts new file mode 100644 index 000000000..c43c62367 --- /dev/null +++ b/test/unit/quota/QuotaStrategy.test.ts @@ -0,0 +1,88 @@ +import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata'; +import { QuotaStrategy } from '../../../src/storage/quota/QuotaStrategy'; +import { UNIT_BYTES } from '../../../src/storage/size-reporter/Size'; +import type { Size } from '../../../src/storage/size-reporter/Size'; +import type { SizeReporter } from '../../../src/storage/size-reporter/SizeReporter'; +import { guardedStreamFrom, pipeSafely } from '../../../src/util/StreamUtil'; +import { mockFs } from '../../util/Util'; + +jest.mock('fs'); + +class QuotaStrategyWrapper extends QuotaStrategy { + public constructor(reporter: SizeReporter, limit: Size) { + super(reporter, limit); + } + + public getAvailableSpace = async(): Promise => ({ unit: UNIT_BYTES, amount: 5 }); + protected getTotalSpaceUsed = async(): Promise => ({ unit: UNIT_BYTES, amount: 5 }); +} + +describe('A QuotaStrategy', (): void => { + let strategy: QuotaStrategyWrapper; + let mockSize: Size; + let mockReporter: jest.Mocked>; + const base = 'http://localhost:3000/'; + const rootFilePath = 'folder'; + + beforeEach((): void => { + jest.restoreAllMocks(); + mockFs(rootFilePath, new Date()); + mockSize = { amount: 2000, unit: UNIT_BYTES }; + mockReporter = { + getSize: jest.fn().mockResolvedValue({ unit: mockSize.unit, amount: 50 }), + getUnit: jest.fn().mockReturnValue(mockSize.unit), + calculateChunkSize: jest.fn(async(chunk: any): Promise => chunk.length), + estimateSize: jest.fn().mockResolvedValue(5), + }; + strategy = new QuotaStrategyWrapper(mockReporter, mockSize); + }); + + describe('constructor()', (): void => { + it('should set the passed parameters as properties.', async(): Promise => { + expect(strategy.limit).toEqual(mockSize); + expect(strategy.reporter).toEqual(mockReporter); + }); + }); + + describe('estimateSize()', (): void => { + it('should return a Size object containing the correct unit and amount.', async(): Promise => { + await expect(strategy.estimateSize(new RepresentationMetadata())).resolves.toEqual( + // This '5' comes from the reporter mock a little up in this file + expect.objectContaining({ unit: mockSize.unit, amount: 5 }), + ); + }); + it('should return undefined when the reporter returns undefined.', async(): Promise => { + mockReporter.estimateSize.mockResolvedValueOnce(undefined); + await expect(strategy.estimateSize(new RepresentationMetadata())).resolves.toBeUndefined(); + }); + }); + + describe('createQuotaGuard()', (): void => { + it('should return a passthrough that destroys the stream when quota is exceeded.', async(): Promise => { + strategy.getAvailableSpace = jest.fn().mockReturnValue({ amount: 50, unit: mockSize.unit }); + const fiftyChars = 'A'.repeat(50); + const stream = guardedStreamFrom(fiftyChars); + const track = await strategy.createQuotaGuard({ path: `${base}nested/file2.txt` }); + const piped = pipeSafely(stream, track); + + for (let i = 0; i < 10; i++) { + stream.push(fiftyChars); + } + + expect(piped.destroyed).toBe(false); + + for (let i = 0; i < 10; i++) { + stream.push(fiftyChars); + } + + expect(piped.destroyed).toBe(false); + + stream.push(fiftyChars); + + const destroy = new Promise((resolve): void => { + piped.on('error', (): void => resolve()); + }); + await expect(destroy).resolves.toBeUndefined(); + }); + }); +}); diff --git a/test/unit/storage/accessors/AtomicFileDataAccessor.test.ts b/test/unit/storage/accessors/AtomicFileDataAccessor.test.ts new file mode 100644 index 000000000..15be725a8 --- /dev/null +++ b/test/unit/storage/accessors/AtomicFileDataAccessor.test.ts @@ -0,0 +1,97 @@ +import 'jest-rdf'; +import type { Readable } from 'stream'; +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; +import { AtomicFileDataAccessor } from '../../../../src/storage/accessors/AtomicFileDataAccessor'; +import { ExtensionBasedMapper } from '../../../../src/storage/mapping/ExtensionBasedMapper'; +import { APPLICATION_OCTET_STREAM } from '../../../../src/util/ContentTypes'; +import type { Guarded } from '../../../../src/util/GuardedStream'; +import { guardedStreamFrom } from '../../../../src/util/StreamUtil'; +import { CONTENT_TYPE } from '../../../../src/util/Vocabularies'; +import { mockFs } from '../../../util/Util'; + +jest.mock('fs'); + +describe('AtomicFileDataAccessor', (): void => { + const rootFilePath = 'uploads'; + const base = 'http://test.com/'; + let accessor: AtomicFileDataAccessor; + let cache: { data: any }; + let metadata: RepresentationMetadata; + let data: Guarded; + + beforeEach(async(): Promise => { + cache = mockFs(rootFilePath, new Date()); + accessor = new AtomicFileDataAccessor( + new ExtensionBasedMapper(base, rootFilePath), + rootFilePath, + './.internal/tempFiles/', + ); + // The 'mkdirSync' in AtomicFileDataAccessor's constructor does not seem to create the folder in the + // cache object used for mocking fs. + // This line creates what represents a folder in the cache object + cache.data['.internal'] = { tempFiles: {}}; + metadata = new RepresentationMetadata(APPLICATION_OCTET_STREAM); + data = guardedStreamFrom([ 'data' ]); + }); + + describe('writing a document', (): void => { + it('writes the data to the corresponding file.', async(): Promise => { + await expect(accessor.writeDocument({ path: `${base}resource` }, data, metadata)).resolves.toBeUndefined(); + expect(cache.data.resource).toBe('data'); + }); + + it('writes metadata to the corresponding metadata file.', async(): Promise => { + metadata = new RepresentationMetadata({ path: `${base}res.ttl` }, + { [CONTENT_TYPE]: 'text/turtle', likes: 'apples' }); + await expect(accessor.writeDocument({ path: `${base}res.ttl` }, data, metadata)).resolves.toBeUndefined(); + expect(cache.data['res.ttl']).toBe('data'); + expect(cache.data['res.ttl.meta']).toMatch(`<${base}res.ttl> "apples".`); + }); + + it('should delete temp file when done writing.', async(): Promise => { + await expect(accessor.writeDocument({ path: `${base}resource` }, data, metadata)).resolves.toBeUndefined(); + expect(Object.keys(cache.data['.internal'].tempFiles)).toHaveLength(0); + expect(cache.data.resource).toBe('data'); + }); + + it('should throw an error when writing the data goes wrong.', async(): Promise => { + data.read = jest.fn((): any => { + data.emit('error', new Error('error')); + return null; + }); + jest.requireMock('fs').promises.stat = jest.fn((): any => ({ + isFile: (): boolean => false, + })); + await expect(accessor.writeDocument({ path: `${base}res.ttl` }, data, metadata)).rejects.toThrow('error'); + }); + + it('should throw when renaming / moving the file goes wrong.', async(): Promise => { + jest.requireMock('fs').promises.rename = jest.fn((): any => { + throw new Error('error'); + }); + jest.requireMock('fs').promises.stat = jest.fn((): any => ({ + isFile: (): boolean => true, + })); + await expect(accessor.writeDocument({ path: `${base}res.ttl` }, data, metadata)).rejects.toThrow('error'); + }); + + it('should (on error) not unlink the temp file if it does not exist.', async(): Promise => { + jest.requireMock('fs').promises.rename = jest.fn((): any => { + throw new Error('error'); + }); + jest.requireMock('fs').promises.stat = jest.fn((): any => ({ + isFile: (): boolean => false, + })); + await expect(accessor.writeDocument({ path: `${base}res.ttl` }, data, metadata)).rejects.toThrow('error'); + }); + + it('should throw when renaming / moving the file goes wrong and the temp file does not exist.', + async(): Promise => { + jest.requireMock('fs').promises.rename = jest.fn((): any => { + throw new Error('error'); + }); + jest.requireMock('fs').promises.stat = jest.fn(); + await expect(accessor.writeDocument({ path: `${base}res.ttl` }, data, metadata)).rejects.toThrow('error'); + }); + }); +}); diff --git a/test/unit/storage/accessors/PassthroughDataAccessor.test.ts b/test/unit/storage/accessors/PassthroughDataAccessor.test.ts new file mode 100644 index 000000000..923d8b67f --- /dev/null +++ b/test/unit/storage/accessors/PassthroughDataAccessor.test.ts @@ -0,0 +1,80 @@ +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; +import type { DataAccessor } from '../../../../src/storage/accessors/DataAccessor'; +import { PassthroughDataAccessor } from '../../../../src/storage/accessors/PassthroughDataAccessor'; +import { guardedStreamFrom } from '../../../../src/util/StreamUtil'; + +describe('ValidatingDataAccessor', (): void => { + let passthrough: PassthroughDataAccessor; + let childAccessor: jest.Mocked; + + const mockIdentifier = { path: 'http://localhost/test.txt' }; + const mockMetadata = new RepresentationMetadata(); + const mockData = guardedStreamFrom('test string'); + const mockRepresentation = new BasicRepresentation(mockData, mockMetadata); + + beforeEach(async(): Promise => { + jest.clearAllMocks(); + childAccessor = { + canHandle: jest.fn(), + writeDocument: jest.fn(), + getData: jest.fn(), + getChildren: jest.fn(), + writeContainer: jest.fn(), + deleteResource: jest.fn(), + getMetadata: jest.fn(), + }; + childAccessor.getChildren = jest.fn(); + passthrough = new PassthroughDataAccessor(childAccessor); + }); + + describe('writeDocument()', (): void => { + it('should call the accessors writeDocument() function.', async(): Promise => { + await passthrough.writeDocument(mockIdentifier, mockData, mockMetadata); + expect(childAccessor.writeDocument).toHaveBeenCalledTimes(1); + expect(childAccessor.writeDocument).toHaveBeenCalledWith(mockIdentifier, mockData, mockMetadata); + }); + }); + describe('canHandle()', (): void => { + it('should call the accessors canHandle() function.', async(): Promise => { + await passthrough.canHandle(mockRepresentation); + expect(childAccessor.canHandle).toHaveBeenCalledTimes(1); + expect(childAccessor.canHandle).toHaveBeenCalledWith(mockRepresentation); + }); + }); + describe('getData()', (): void => { + it('should call the accessors getData() function.', async(): Promise => { + await passthrough.getData(mockIdentifier); + expect(childAccessor.getData).toHaveBeenCalledTimes(1); + expect(childAccessor.getData).toHaveBeenCalledWith(mockIdentifier); + }); + }); + describe('getMetadata()', (): void => { + it('should call the accessors getMetadata() function.', async(): Promise => { + await passthrough.getMetadata(mockIdentifier); + expect(childAccessor.getMetadata).toHaveBeenCalledTimes(1); + expect(childAccessor.getMetadata).toHaveBeenCalledWith(mockIdentifier); + }); + }); + describe('getChildren()', (): void => { + it('should call the accessors getChildren() function.', async(): Promise => { + passthrough.getChildren(mockIdentifier); + expect(childAccessor.getChildren).toHaveBeenCalledTimes(1); + expect(childAccessor.getChildren).toHaveBeenCalledWith(mockIdentifier); + }); + }); + describe('deleteResource()', (): void => { + it('should call the accessors deleteResource() function.', async(): Promise => { + await passthrough.deleteResource(mockIdentifier); + expect(childAccessor.deleteResource).toHaveBeenCalledTimes(1); + expect(childAccessor.deleteResource).toHaveBeenCalledWith(mockIdentifier); + }); + }); + describe('writeContainer()', (): void => { + it('should call the accessors writeContainer() function.', async(): Promise => { + await passthrough.writeContainer(mockIdentifier, mockMetadata); + expect(childAccessor.writeContainer).toHaveBeenCalledTimes(1); + expect(childAccessor.writeContainer).toHaveBeenCalledWith(mockIdentifier, mockMetadata); + }); + }); +}); diff --git a/test/unit/storage/accessors/ValidatingDataAccessor.test.ts b/test/unit/storage/accessors/ValidatingDataAccessor.test.ts new file mode 100644 index 000000000..645526c63 --- /dev/null +++ b/test/unit/storage/accessors/ValidatingDataAccessor.test.ts @@ -0,0 +1,54 @@ +import type { Validator, ValidatorInput } from '../../../../src/http/auxiliary/Validator'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { Representation } from '../../../../src/http/representation/Representation'; +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; +import type { DataAccessor } from '../../../../src/storage/accessors/DataAccessor'; +import { ValidatingDataAccessor } from '../../../../src/storage/accessors/ValidatingDataAccessor'; +import { guardedStreamFrom } from '../../../../src/util/StreamUtil'; + +describe('ValidatingDataAccessor', (): void => { + let validatingAccessor: ValidatingDataAccessor; + let childAccessor: jest.Mocked; + let validator: jest.Mocked; + + const mockIdentifier = { path: 'http://localhost/test.txt' }; + const mockMetadata = new RepresentationMetadata(); + const mockData = guardedStreamFrom('test string'); + const mockRepresentation = new BasicRepresentation(mockData, mockMetadata); + + beforeEach(async(): Promise => { + jest.clearAllMocks(); + childAccessor = { + writeDocument: jest.fn(), + writeContainer: jest.fn(), + } as any; + childAccessor.getChildren = jest.fn(); + validator = { + handleSafe: jest.fn(async(input: ValidatorInput): Promise => input.representation), + } as any; + validatingAccessor = new ValidatingDataAccessor(childAccessor, validator); + }); + + describe('writeDocument()', (): void => { + it('should call the validator\'s handleSafe() function.', async(): Promise => { + await validatingAccessor.writeDocument(mockIdentifier, mockData, mockMetadata); + expect(validator.handleSafe).toHaveBeenCalledTimes(1); + expect(validator.handleSafe).toHaveBeenCalledWith({ + representation: mockRepresentation, + identifier: mockIdentifier, + }); + }); + it('should call the accessors writeDocument() function.', async(): Promise => { + await validatingAccessor.writeDocument(mockIdentifier, mockData, mockMetadata); + expect(childAccessor.writeDocument).toHaveBeenCalledTimes(1); + expect(childAccessor.writeDocument).toHaveBeenCalledWith(mockIdentifier, mockData, mockMetadata); + }); + }); + describe('writeContainer()', (): void => { + it('should call the accessors writeContainer() function.', async(): Promise => { + await validatingAccessor.writeContainer(mockIdentifier, mockMetadata); + expect(childAccessor.writeContainer).toHaveBeenCalledTimes(1); + expect(childAccessor.writeContainer).toHaveBeenCalledWith(mockIdentifier, mockMetadata); + }); + }); +}); diff --git a/test/unit/storage/size-reporter/FileSizeReporter.test.ts b/test/unit/storage/size-reporter/FileSizeReporter.test.ts new file mode 100644 index 000000000..b471cb1b9 --- /dev/null +++ b/test/unit/storage/size-reporter/FileSizeReporter.test.ts @@ -0,0 +1,132 @@ +import { promises as fsPromises } from 'fs'; +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; +import type { FileIdentifierMapper, ResourceLink } from '../../../../src/storage/mapping/FileIdentifierMapper'; +import { FileSizeReporter } from '../../../../src/storage/size-reporter/FileSizeReporter'; +import { UNIT_BYTES } from '../../../../src/storage/size-reporter/Size'; +import { joinFilePath } from '../../../../src/util/PathUtil'; +import { mockFs } from '../../../util/Util'; + +jest.mock('fs'); + +describe('A FileSizeReporter', (): void => { + // Folder size is fixed to 4 in the mock + const folderSize = 4; + const mapper: jest.Mocked = { + mapFilePathToUrl: jest.fn(), + mapUrlToFilePath: jest.fn().mockImplementation((id: ResourceIdentifier): ResourceLink => ({ + filePath: id.path, + identifier: id, + isMetadata: false, + })), + }; + const fileRoot = joinFilePath(process.cwd(), '/test-folder/'); + const fileSizeReporter = new FileSizeReporter( + mapper, + fileRoot, + [ '^/\\.internal$' ], + ); + + beforeEach(async(): Promise => { + mockFs(fileRoot); + }); + + it('should work without the ignoreFolders constructor parameter.', async(): Promise => { + const tempFileSizeReporter = new FileSizeReporter( + mapper, + fileRoot, + ); + + const testFile = joinFilePath(fileRoot, '/test.txt'); + await fsPromises.writeFile(testFile, 'A'.repeat(20)); + + const result = tempFileSizeReporter.getSize({ path: testFile }); + await expect(result).resolves.toBeDefined(); + expect((await result).amount).toBe(20); + }); + + it('should report the right file size.', async(): Promise => { + const testFile = joinFilePath(fileRoot, '/test.txt'); + await fsPromises.writeFile(testFile, 'A'.repeat(20)); + + const result = fileSizeReporter.getSize({ path: testFile }); + await expect(result).resolves.toBeDefined(); + expect((await result).amount).toBe(20); + }); + + it('should work recursively.', async(): Promise => { + const containerFile = joinFilePath(fileRoot, '/test-folder-1/'); + await fsPromises.mkdir(containerFile, { recursive: true }); + const testFile = joinFilePath(containerFile, '/test.txt'); + await fsPromises.writeFile(testFile, 'A'.repeat(20)); + + const fileSize = fileSizeReporter.getSize({ path: testFile }); + const containerSize = fileSizeReporter.getSize({ path: containerFile }); + + await expect(fileSize).resolves.toEqual(expect.objectContaining({ amount: 20 })); + await expect(containerSize).resolves.toEqual(expect.objectContaining({ amount: 20 + folderSize })); + }); + + it('should not count files located in an ignored folder.', async(): Promise => { + const containerFile = joinFilePath(fileRoot, '/test-folder-2/'); + await fsPromises.mkdir(containerFile, { recursive: true }); + const testFile = joinFilePath(containerFile, '/test.txt'); + await fsPromises.writeFile(testFile, 'A'.repeat(20)); + + const internalContainerFile = joinFilePath(fileRoot, '/.internal/'); + await fsPromises.mkdir(internalContainerFile, { recursive: true }); + const internalTestFile = joinFilePath(internalContainerFile, '/test.txt'); + await fsPromises.writeFile(internalTestFile, 'A'.repeat(30)); + + const fileSize = fileSizeReporter.getSize({ path: testFile }); + const containerSize = fileSizeReporter.getSize({ path: containerFile }); + const rootSize = fileSizeReporter.getSize({ path: fileRoot }); + + const expectedFileSize = 20; + const expectedContainerSize = 20 + folderSize; + const expectedRootSize = expectedContainerSize + folderSize; + + await expect(fileSize).resolves.toEqual(expect.objectContaining({ amount: expectedFileSize })); + await expect(containerSize).resolves.toEqual(expect.objectContaining({ amount: expectedContainerSize })); + await expect(rootSize).resolves.toEqual(expect.objectContaining({ amount: expectedRootSize })); + }); + + it('should have the unit in its return value.', async(): Promise => { + const testFile = joinFilePath(fileRoot, '/test2.txt'); + await fsPromises.writeFile(testFile, 'A'.repeat(20)); + + const result = fileSizeReporter.getSize({ path: testFile }); + await expect(result).resolves.toBeDefined(); + expect((await result).unit).toBe(UNIT_BYTES); + }); + + it('getUnit() should return UNIT_BYTES.', (): void => { + expect(fileSizeReporter.getUnit()).toBe(UNIT_BYTES); + }); + + it('should return 0 when the size of a non existent file is requested.', async(): Promise => { + const result = fileSizeReporter.getSize({ path: joinFilePath(fileRoot, '/test.txt') }); + await expect(result).resolves.toEqual(expect.objectContaining({ amount: 0 })); + }); + + it('should calculate the chunk size correctly.', async(): Promise => { + const testString = 'testesttesttesttest==testtest'; + const result = fileSizeReporter.calculateChunkSize(testString); + await expect(result).resolves.toEqual(testString.length); + }); + + describe('estimateSize()', (): void => { + it('should return the content-length.', async(): Promise => { + const metadata = new RepresentationMetadata(); + metadata.contentLength = 100; + await expect(fileSizeReporter.estimateSize(metadata)).resolves.toEqual(100); + }); + it( + 'should return undefined if no content-length is present in the metadata.', + async(): Promise => { + const metadata = new RepresentationMetadata(); + await expect(fileSizeReporter.estimateSize(metadata)).resolves.toBeUndefined(); + }, + ); + }); +}); diff --git a/test/unit/storage/validators/QuotaValidator.test.ts b/test/unit/storage/validators/QuotaValidator.test.ts new file mode 100644 index 000000000..e496f5842 --- /dev/null +++ b/test/unit/storage/validators/QuotaValidator.test.ts @@ -0,0 +1,120 @@ +import type { Readable } from 'stream'; +import { PassThrough } from 'stream'; +import type { ValidatorInput } from '../../../../src/http/auxiliary/Validator'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; +import type { QuotaStrategy } from '../../../../src/storage/quota/QuotaStrategy'; +import { UNIT_BYTES } from '../../../../src/storage/size-reporter/Size'; +import type { SizeReporter } from '../../../../src/storage/size-reporter/SizeReporter'; +import { QuotaValidator } from '../../../../src/storage/validators/QuotaValidator'; +import { guardStream } from '../../../../src/util/GuardedStream'; +import type { Guarded } from '../../../../src/util/GuardedStream'; +import { guardedStreamFrom, readableToString } from '../../../../src/util/StreamUtil'; + +describe('QuotaValidator', (): void => { + let mockedStrategy: jest.Mocked; + let validator: QuotaValidator; + let identifier: ResourceIdentifier; + let mockMetadata: RepresentationMetadata; + let mockData: Guarded; + let mockInput: ValidatorInput; + let mockReporter: jest.Mocked>; + + beforeEach((): void => { + jest.clearAllMocks(); + identifier = { path: 'http://localhost/' }; + mockMetadata = new RepresentationMetadata(); + mockData = guardedStreamFrom([ 'test string' ]); + mockInput = { + representation: new BasicRepresentation(mockData, mockMetadata), + identifier, + }; + mockReporter = { + getSize: jest.fn(), + getUnit: jest.fn(), + calculateChunkSize: jest.fn(), + estimateSize: jest.fn().mockResolvedValue(8), + }; + mockedStrategy = { + reporter: mockReporter, + limit: { unit: UNIT_BYTES, amount: 8 }, + getAvailableSpace: jest.fn().mockResolvedValue({ unit: UNIT_BYTES, amount: 10 }), + estimateSize: jest.fn().mockResolvedValue({ unit: UNIT_BYTES, amount: 8 }), + createQuotaGuard: jest.fn().mockResolvedValue(guardStream(new PassThrough())), + } as any; + validator = new QuotaValidator(mockedStrategy); + }); + + describe('handle()', (): void => { + // Step 2 + it('should destroy the stream when estimated size is larger than the available size.', async(): Promise => { + mockedStrategy.estimateSize.mockResolvedValueOnce({ unit: UNIT_BYTES, amount: 11 }); + + const result = validator.handle(mockInput); + await expect(result).resolves.toBeDefined(); + const awaitedResult = await result; + + const prom = new Promise((resolve, reject): void => { + awaitedResult.data.on('error', (): void => resolve()); + awaitedResult.data.on('end', (): void => reject(new Error('reject'))); + }); + + // Consume the stream + await expect(readableToString(awaitedResult.data)) + .rejects.toThrow('Quota exceeded: Advertised Content-Length is'); + await expect(prom).resolves.toBeUndefined(); + }); + + // Step 3 + it('should destroy the stream when quota is exceeded during write.', async(): Promise => { + mockedStrategy.createQuotaGuard.mockResolvedValueOnce(guardStream(new PassThrough({ + async transform(this): Promise { + this.destroy(new Error('error')); + }, + }))); + + const result = validator.handle(mockInput); + await expect(result).resolves.toBeDefined(); + const awaitedResult = await result; + + const prom = new Promise((resolve, reject): void => { + awaitedResult.data.on('error', (): void => resolve()); + awaitedResult.data.on('end', (): void => reject(new Error('reject'))); + }); + + // Consume the stream + await expect(readableToString(awaitedResult.data)).rejects.toThrow('error'); + expect(mockedStrategy.createQuotaGuard).toHaveBeenCalledTimes(1); + await expect(prom).resolves.toBeUndefined(); + }); + + // Step 4 + it('should throw when quota were exceeded after stream was finished.', async(): Promise => { + const result = validator.handle(mockInput); + + // Putting this after the handle / before consuming the stream will only effect + // this function in the flush part of the code. + mockedStrategy.getAvailableSpace.mockResolvedValueOnce({ unit: UNIT_BYTES, amount: -100 }); + + await expect(result).resolves.toBeDefined(); + const awaitedResult = await result; + + const prom = new Promise((resolve, reject): void => { + awaitedResult.data.on('error', (): void => resolve()); + awaitedResult.data.on('end', (): void => reject(new Error('reject'))); + }); + + // Consume the stream + await expect(readableToString(awaitedResult.data)).rejects.toThrow('Quota exceeded after write completed'); + await expect(prom).resolves.toBeUndefined(); + }); + + it('should return a stream that is consumable without error if quota isn\'t exceeded.', async(): Promise => { + const result = validator.handle(mockInput); + await expect(result).resolves.toBeDefined(); + const awaitedResult = await result; + await expect(readableToString(awaitedResult.data)).resolves.toBe('test string'); + }); + }); +}); diff --git a/test/unit/util/errors/HttpError.test.ts b/test/unit/util/errors/HttpError.test.ts index 5853df215..c62e6d3ef 100644 --- a/test/unit/util/errors/HttpError.test.ts +++ b/test/unit/util/errors/HttpError.test.ts @@ -7,6 +7,7 @@ import { InternalServerError } from '../../../../src/util/errors/InternalServerE import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError'; import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import { PayloadHttpError } from '../../../../src/util/errors/PayloadHttpError'; import { PreconditionFailedHttpError } from '../../../../src/util/errors/PreconditionFailedHttpError'; import { UnauthorizedHttpError } from '../../../../src/util/errors/UnauthorizedHttpError'; import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError'; @@ -27,6 +28,7 @@ describe('HttpError', (): void => { [ 'MethodNotAllowedHttpError', 405, MethodNotAllowedHttpError ], [ 'ConflictHttpError', 409, ConflictHttpError ], [ 'PreconditionFailedHttpError', 412, PreconditionFailedHttpError ], + [ 'PayloadHttpError', 413, PayloadHttpError ], [ 'UnsupportedMediaTypeHttpError', 415, UnsupportedMediaTypeHttpError ], [ 'InternalServerError', 500, InternalServerError ], [ 'NotImplementedHttpError', 501, NotImplementedHttpError ], diff --git a/test/util/Util.ts b/test/util/Util.ts index 40e652c00..9b6ac63f8 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -19,6 +19,8 @@ const portNames = [ 'SparqlStorage', 'Subdomains', 'WebSocketsProtocol', + 'PodQuota', + 'GlobalQuota', // Unit 'BaseHttpServerFactory', ] as const; @@ -122,7 +124,7 @@ export function mockFs(rootFilepath?: string, time?: Date): { data: any } { isFile: (): boolean => typeof folder[name] === 'string', isDirectory: (): boolean => typeof folder[name] === 'object', isSymbolicLink: (): boolean => typeof folder[name] === 'symbol', - size: typeof folder[name] === 'string' ? folder[name].length : 0, + size: typeof folder[name] === 'string' ? folder[name].length : 4, mtime: time, } as Stats; }, @@ -199,6 +201,21 @@ export function mockFs(rootFilepath?: string, time?: Date): { data: any } { const { folder, name } = getFolder(path); folder[name] = data; }, + async rename(path: string, destination: string): Promise { + const { folder, name } = getFolder(path); + if (!folder[name]) { + throwSystemError('ENOENT'); + } + if (!(await this.lstat(path)).isFile()) { + throwSystemError('EISDIR'); + } + + const { folder: folderDest, name: nameDest } = getFolder(destination); + folderDest[nameDest] = folder[name]; + + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete folder[name]; + }, }, }; From bc6203f3e8d19736b48caa14850a86993b698426 Mon Sep 17 00:00:00 2001 From: Thomas Dupont Date: Fri, 21 Jan 2022 13:47:54 +0100 Subject: [PATCH 15/39] docs: Update release notes with official docker image publishing. (#1123) --- RELEASE_NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3b88c3406..ada7c85f9 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -5,6 +5,7 @@ - The Identity Provider now uses the `webid` scope as required for Solid-OIDC. - The `VoidLocker` can be used to disable locking for development/testing purposes. This can be enabled by changing the `/config/util/resource-locker/` import to `debug-void.json` - Added support for setting a quota on the server. See the `config/quota-file.json` config for an example. +- An official docker image is now built on each version tag and published at https://hub.docker.com/r/solidproject/community-server. ### Configuration changes You might need to make changes to your v2 configuration if you use a custom config. From 1afed65368f98f4fda7bdd8f9fc5071f51d4dc5b Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Fri, 14 Jan 2022 14:54:28 +0100 Subject: [PATCH 16/39] feat: Return correct status codes for invalid requests --- .../handler/components/operation-handler.json | 4 ++ config/ldp/modes/default.json | 29 ++++++++++- .../storage/middleware/stores/patching.json | 4 ++ .../permissions/ModesExtractor.ts | 3 ++ ...actor.ts => SparqlUpdateModesExtractor.ts} | 12 +++-- src/index.ts | 5 +- src/util/handlers/MethodFilterHandler.ts | 30 +++++++++++ src/util/handlers/StaticThrowHandler.ts | 18 +++++++ .../integration/LdpHandlerWithoutAuth.test.ts | 10 ++++ ....ts => SparqlUpdateModesExtractor.test.ts} | 14 ++--- .../util/handlers/MethodFilterHandler.test.ts | 52 +++++++++++++++++++ .../util/handlers/StaticThrowHandler.test.ts | 15 ++++++ 12 files changed, 179 insertions(+), 17 deletions(-) rename src/authorization/permissions/{SparqlPatchModesExtractor.ts => SparqlUpdateModesExtractor.ts} (89%) create mode 100644 src/util/handlers/MethodFilterHandler.ts create mode 100644 src/util/handlers/StaticThrowHandler.ts rename test/unit/authorization/permissions/{SparqlPatchModesExtractor.test.ts => SparqlUpdateModesExtractor.test.ts} (85%) create mode 100644 test/unit/util/handlers/MethodFilterHandler.test.ts create mode 100644 test/unit/util/handlers/StaticThrowHandler.test.ts diff --git a/config/ldp/handler/components/operation-handler.json b/config/ldp/handler/components/operation-handler.json index b3ff0d3fa..582354ca9 100644 --- a/config/ldp/handler/components/operation-handler.json +++ b/config/ldp/handler/components/operation-handler.json @@ -28,6 +28,10 @@ { "@type": "PatchOperationHandler", "store": { "@id": "urn:solid-server:default:ResourceStore" } + }, + { + "@type": "StaticThrowHandler", + "error": { "@type": "MethodNotAllowedHttpError" } } ] } diff --git a/config/ldp/modes/default.json b/config/ldp/modes/default.json index b83d49299..1fa1e27ef 100644 --- a/config/ldp/modes/default.json +++ b/config/ldp/modes/default.json @@ -6,9 +6,34 @@ "@id": "urn:solid-server:default:ModesExtractor", "@type": "WaterfallHandler", "handlers": [ - { "@type": "MethodModesExtractor" }, - { "@type": "SparqlPatchModesExtractor" } + { + "comment": "Extract access modes for PATCH requests based on the request body.", + "@id": "urn:solid-server:default:PatchModesExtractor" + }, + { + "comment": "Extract access modes based on the HTTP method.", + "@type": "MethodModesExtractor" + }, + { + "@type": "StaticThrowHandler", + "error": { "@type": "MethodNotAllowedHttpError" } + } ] + }, + { + "@id": "urn:solid-server:default:PatchModesExtractor", + "@type": "MethodFilterHandler", + "methods": [ "PATCH" ], + "source": { + "@type": "WaterfallHandler", + "handlers": [ + { "@type": "SparqlUpdateModesExtractor" }, + { + "@type": "StaticThrowHandler", + "error": { "@type": "UnsupportedMediaTypeHttpError" } + } + ] + } } ] } diff --git a/config/storage/middleware/stores/patching.json b/config/storage/middleware/stores/patching.json index 70368a170..033be5332 100644 --- a/config/storage/middleware/stores/patching.json +++ b/config/storage/middleware/stores/patching.json @@ -22,6 +22,10 @@ "converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, "intermediateType": "internal/quads", "defaultType": "text/turtle" + }, + { + "@type": "StaticThrowHandler", + "error": { "@type": "UnsupportedMediaTypeHttpError" } } ] } diff --git a/src/authorization/permissions/ModesExtractor.ts b/src/authorization/permissions/ModesExtractor.ts index a62691834..1ded7055f 100644 --- a/src/authorization/permissions/ModesExtractor.ts +++ b/src/authorization/permissions/ModesExtractor.ts @@ -2,4 +2,7 @@ import type { Operation } from '../../http/Operation'; import { AsyncHandler } from '../../util/handlers/AsyncHandler'; import type { AccessMode } from './Permissions'; +/** + * Extracts all {@link AccessMode}s that are necessary to execute the given {@link Operation}. + */ export abstract class ModesExtractor extends AsyncHandler> {} diff --git a/src/authorization/permissions/SparqlPatchModesExtractor.ts b/src/authorization/permissions/SparqlUpdateModesExtractor.ts similarity index 89% rename from src/authorization/permissions/SparqlPatchModesExtractor.ts rename to src/authorization/permissions/SparqlUpdateModesExtractor.ts index cae2798d6..1cdf3f223 100644 --- a/src/authorization/permissions/SparqlPatchModesExtractor.ts +++ b/src/authorization/permissions/SparqlUpdateModesExtractor.ts @@ -6,11 +6,13 @@ import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpErr import { ModesExtractor } from './ModesExtractor'; import { AccessMode } from './Permissions'; -export class SparqlPatchModesExtractor extends ModesExtractor { - public async canHandle({ method, body }: Operation): Promise { - if (method !== 'PATCH') { - throw new NotImplementedHttpError(`Cannot determine permissions of ${method}, only PATCH.`); - } +/** + * Generates permissions for a SPARQL DELETE/INSERT body. + * Updates with only an INSERT can be done with just append permissions, + * while DELETEs require write permissions as well. + */ +export class SparqlUpdateModesExtractor extends ModesExtractor { + public async canHandle({ body }: Operation): Promise { if (!this.isSparql(body)) { throw new NotImplementedHttpError('Cannot determine permissions of non-SPARQL patches.'); } diff --git a/src/index.ts b/src/index.ts index 964d4e2a7..cd70639ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,7 @@ export * from './authorization/access/AgentGroupAccessChecker'; export * from './authorization/permissions/Permissions'; export * from './authorization/permissions/ModesExtractor'; export * from './authorization/permissions/MethodModesExtractor'; -export * from './authorization/permissions/SparqlPatchModesExtractor'; +export * from './authorization/permissions/SparqlUpdateModesExtractor'; // Authorization export * from './authorization/AllStaticReader'; @@ -359,9 +359,12 @@ export * from './util/errors/UnsupportedMediaTypeHttpError'; export * from './util/handlers/AsyncHandler'; export * from './util/handlers/BooleanHandler'; export * from './util/handlers/ConditionalHandler'; +export * from './util/handlers/HandlerUtil'; +export * from './util/handlers/MethodFilterHandler'; export * from './util/handlers/ParallelHandler'; export * from './util/handlers/SequenceHandler'; export * from './util/handlers/StaticHandler'; +export * from './util/handlers/StaticThrowHandler'; export * from './util/handlers/UnionHandler'; export * from './util/handlers/UnsupportedAsyncHandler'; export * from './util/handlers/WaterfallHandler'; diff --git a/src/util/handlers/MethodFilterHandler.ts b/src/util/handlers/MethodFilterHandler.ts new file mode 100644 index 000000000..a358547e2 --- /dev/null +++ b/src/util/handlers/MethodFilterHandler.ts @@ -0,0 +1,30 @@ +import { NotImplementedHttpError } from '../errors/NotImplementedHttpError'; +import { AsyncHandler } from './AsyncHandler'; + +/** + * Only accepts requests where the input has a `method` field that matches any one of the given methods. + * In case of a match, the input will be sent to the source handler. + */ +export class MethodFilterHandler extends AsyncHandler { + private readonly methods: string[]; + private readonly source: AsyncHandler; + + public constructor(methods: string[], source: AsyncHandler) { + super(); + this.methods = methods; + this.source = source; + } + + public async canHandle(input: TIn): Promise { + if (!this.methods.includes(input.method)) { + throw new NotImplementedHttpError( + `Cannot determine permissions of ${input.method}, only ${this.methods.join(',')}.`, + ); + } + await this.source.canHandle(input); + } + + public async handle(input: TIn): Promise { + return this.source.handle(input); + } +} diff --git a/src/util/handlers/StaticThrowHandler.ts b/src/util/handlers/StaticThrowHandler.ts new file mode 100644 index 000000000..4df8a3f1d --- /dev/null +++ b/src/util/handlers/StaticThrowHandler.ts @@ -0,0 +1,18 @@ +import type { HttpError } from '../errors/HttpError'; +import { AsyncHandler } from './AsyncHandler'; + +/** + * Utility handler that can handle all input and always throws the given error. + */ +export class StaticThrowHandler extends AsyncHandler { + private readonly error: HttpError; + + public constructor(error: HttpError) { + super(); + this.error = error; + } + + public async handle(): Promise { + throw this.error; + } +} diff --git a/test/integration/LdpHandlerWithoutAuth.test.ts b/test/integration/LdpHandlerWithoutAuth.test.ts index 96dc905ec..12f4f82bd 100644 --- a/test/integration/LdpHandlerWithoutAuth.test.ts +++ b/test/integration/LdpHandlerWithoutAuth.test.ts @@ -386,4 +386,14 @@ describe.each(stores)('An LDP handler allowing all requests %s', (name, { storeC // DELETE expect(await deleteResource(documentUrl)).toBeUndefined(); }); + + it('returns 405 for unsupported methods.', async(): Promise => { + const response = await fetch(baseUrl, { method: 'TRACE' }); + expect(response.status).toBe(405); + }); + + it('returns 415 for unsupported PATCH types.', async(): Promise => { + const response = await fetch(baseUrl, { method: 'PATCH', headers: { 'content-type': 'text/plain' }, body: 'abc' }); + expect(response.status).toBe(415); + }); }); diff --git a/test/unit/authorization/permissions/SparqlPatchModesExtractor.test.ts b/test/unit/authorization/permissions/SparqlUpdateModesExtractor.test.ts similarity index 85% rename from test/unit/authorization/permissions/SparqlPatchModesExtractor.test.ts rename to test/unit/authorization/permissions/SparqlUpdateModesExtractor.test.ts index 9fdf97a1f..eb03643f6 100644 --- a/test/unit/authorization/permissions/SparqlPatchModesExtractor.test.ts +++ b/test/unit/authorization/permissions/SparqlUpdateModesExtractor.test.ts @@ -1,25 +1,21 @@ import { Factory } from 'sparqlalgebrajs'; import { AccessMode } from '../../../../src/authorization/permissions/Permissions'; -import { SparqlPatchModesExtractor } from '../../../../src/authorization/permissions/SparqlPatchModesExtractor'; +import { SparqlUpdateModesExtractor } from '../../../../src/authorization/permissions/SparqlUpdateModesExtractor'; import type { Operation } from '../../../../src/http/Operation'; import type { SparqlUpdatePatch } from '../../../../src/http/representation/SparqlUpdatePatch'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; -describe('A SparqlPatchModesExtractor', (): void => { - const extractor = new SparqlPatchModesExtractor(); +describe('A SparqlUpdateModesExtractor', (): void => { + const extractor = new SparqlUpdateModesExtractor(); const factory = new Factory(); - it('can only handle (composite) SPARQL DELETE/INSERT PATCH operations.', async(): Promise => { + it('can only handle (composite) SPARQL DELETE/INSERT operations.', async(): Promise => { const operation = { method: 'PATCH', body: { algebra: factory.createDeleteInsert() }} as unknown as Operation; await expect(extractor.canHandle(operation)).resolves.toBeUndefined(); (operation.body as SparqlUpdatePatch).algebra = factory.createCompositeUpdate([ factory.createDeleteInsert() ]); await expect(extractor.canHandle(operation)).resolves.toBeUndefined(); - let result = extractor.canHandle({ ...operation, method: 'GET' }); - await expect(result).rejects.toThrow(NotImplementedHttpError); - await expect(result).rejects.toThrow('Cannot determine permissions of GET, only PATCH.'); - - result = extractor.canHandle({ ...operation, body: {} as SparqlUpdatePatch }); + let result = extractor.canHandle({ ...operation, body: {} as SparqlUpdatePatch }); await expect(result).rejects.toThrow(NotImplementedHttpError); await expect(result).rejects.toThrow('Cannot determine permissions of non-SPARQL patches.'); diff --git a/test/unit/util/handlers/MethodFilterHandler.test.ts b/test/unit/util/handlers/MethodFilterHandler.test.ts new file mode 100644 index 000000000..0b1ef1a49 --- /dev/null +++ b/test/unit/util/handlers/MethodFilterHandler.test.ts @@ -0,0 +1,52 @@ +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import type { AsyncHandler } from '../../../../src/util/handlers/AsyncHandler'; +import { + MethodFilterHandler, +} from '../../../../src/util/handlers/MethodFilterHandler'; + +describe('A MethodFilterHandler', (): void => { + const modes = [ 'PATCH', 'POST' ]; + const result = 'RESULT'; + let operation: Operation; + let source: jest.Mocked>; + let handler: MethodFilterHandler; + + beforeEach(async(): Promise => { + operation = { + method: 'PATCH', + preferences: {}, + permissionSet: {}, + target: { path: 'http://example.com/foo' }, + body: new BasicRepresentation(), + }; + + source = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue(result), + } as any; + + handler = new MethodFilterHandler(modes, source); + }); + + it('rejects unknown methods.', async(): Promise => { + operation.method = 'GET'; + await expect(handler.canHandle(operation)).rejects.toThrow(NotImplementedHttpError); + }); + + it('checks if the source handle supports the request.', async(): Promise => { + operation.method = 'PATCH'; + await expect(handler.canHandle(operation)).resolves.toBeUndefined(); + operation.method = 'POST'; + await expect(handler.canHandle(operation)).resolves.toBeUndefined(); + source.canHandle.mockRejectedValueOnce(new Error('not supported')); + await expect(handler.canHandle(operation)).rejects.toThrow('not supported'); + expect(source.canHandle).toHaveBeenLastCalledWith(operation); + }); + + it('calls the source extractor.', async(): Promise => { + await expect(handler.handle(operation)).resolves.toBe(result); + expect(source.handle).toHaveBeenLastCalledWith(operation); + }); +}); diff --git a/test/unit/util/handlers/StaticThrowHandler.test.ts b/test/unit/util/handlers/StaticThrowHandler.test.ts new file mode 100644 index 000000000..3fc561558 --- /dev/null +++ b/test/unit/util/handlers/StaticThrowHandler.test.ts @@ -0,0 +1,15 @@ +import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; +import { StaticThrowHandler } from '../../../../src/util/handlers/StaticThrowHandler'; + +describe('A StaticThrowHandler', (): void => { + const error = new BadRequestHttpError(); + const handler = new StaticThrowHandler(error); + + it('can handle all requests.', async(): Promise => { + await expect(handler.canHandle({})).resolves.toBeUndefined(); + }); + + it('always throws the given error.', async(): Promise => { + await expect(handler.handle()).rejects.toThrow(error); + }); +}); From a9941ebe7880cc9bb136786d721c1ba76bda888a Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Thu, 20 Jan 2022 10:48:47 +0100 Subject: [PATCH 17/39] feat: Add support for N3 Patch --- RELEASE_NOTES.md | 1 + .../handler/components/request-parser.json | 15 +- .../ldp/metadata-writer/writers/constant.json | 2 +- config/ldp/modes/default.json | 1 + .../storage/middleware/stores/patching.json | 13 +- package-lock.json | 31 +- package.json | 1 + .../permissions/N3PatchModesExtractor.ts | 44 ++ src/http/input/body/N3PatchBodyParser.ts | 138 ++++++ src/http/representation/N3Patch.ts | 18 + src/index.ts | 3 + src/storage/patch/N3Patcher.ts | 160 +++++++ src/storage/patch/SparqlUpdatePatcher.ts | 22 +- src/util/ContentTypes.ts | 1 + src/util/QuadUtil.ts | 22 + src/util/Vocabularies.ts | 5 + .../errors/UnprocessableEntityHttpError.ts | 15 + src/util/handlers/MethodFilterHandler.ts | 29 +- test/.eslintrc.js | 3 + test/integration/N3Patch.test.ts | 398 ++++++++++++++++++ .../permissions/N3PatchModesExtractor.test.ts | 62 +++ .../http/input/body/N3PatchBodyParser.test.ts | 204 +++++++++ test/unit/storage/patch/N3Patcher.test.ts | 155 +++++++ test/unit/util/QuadUtil.test.ts | 16 +- test/unit/util/errors/HttpError.test.ts | 2 + .../util/handlers/MethodFilterHandler.test.ts | 13 +- test/util/FetchUtil.ts | 2 +- test/util/Util.ts | 1 + 28 files changed, 1331 insertions(+), 46 deletions(-) create mode 100644 src/authorization/permissions/N3PatchModesExtractor.ts create mode 100644 src/http/input/body/N3PatchBodyParser.ts create mode 100644 src/http/representation/N3Patch.ts create mode 100644 src/storage/patch/N3Patcher.ts create mode 100644 src/util/errors/UnprocessableEntityHttpError.ts create mode 100644 test/integration/N3Patch.test.ts create mode 100644 test/unit/authorization/permissions/N3PatchModesExtractor.test.ts create mode 100644 test/unit/http/input/body/N3PatchBodyParser.test.ts create mode 100644 test/unit/storage/patch/N3Patcher.test.ts diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ada7c85f9..7e91caf8c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,6 +6,7 @@ - The `VoidLocker` can be used to disable locking for development/testing purposes. This can be enabled by changing the `/config/util/resource-locker/` import to `debug-void.json` - Added support for setting a quota on the server. See the `config/quota-file.json` config for an example. - An official docker image is now built on each version tag and published at https://hub.docker.com/r/solidproject/community-server. +- Added support for N3 Patch. ### Configuration changes You might need to make changes to your v2 configuration if you use a custom config. diff --git a/config/ldp/handler/components/request-parser.json b/config/ldp/handler/components/request-parser.json index da90f2480..af950558e 100644 --- a/config/ldp/handler/components/request-parser.json +++ b/config/ldp/handler/components/request-parser.json @@ -16,10 +16,23 @@ "args_bodyParser": { "@type": "WaterfallHandler", "handlers": [ - { "@type": "SparqlUpdateBodyParser" }, + { "@id": "urn:solid-server:default:PatchBodyParser" }, { "@type": "RawBodyParser" } ] } + }, + { + "comment": "Handles body parsing for PATCH requests. Those requests need to generate an interpreted Patch body.", + "@id": "urn:solid-server:default:PatchBodyParser", + "@type": "MethodFilterHandler", + "methods": [ "PATCH" ], + "source": { + "@type": "WaterfallHandler", + "handlers": [ + { "@type": "N3PatchBodyParser" }, + { "@type": "SparqlUpdateBodyParser" } + ] + } } ] } diff --git a/config/ldp/metadata-writer/writers/constant.json b/config/ldp/metadata-writer/writers/constant.json index aaaaf8ef0..ea4455d4b 100644 --- a/config/ldp/metadata-writer/writers/constant.json +++ b/config/ldp/metadata-writer/writers/constant.json @@ -8,7 +8,7 @@ "headers": [ { "ConstantMetadataWriter:_headers_key": "Accept-Patch", - "ConstantMetadataWriter:_headers_value": "application/sparql-update" + "ConstantMetadataWriter:_headers_value": "application/sparql-update, text/n3" }, { "ConstantMetadataWriter:_headers_key": "Allow", diff --git a/config/ldp/modes/default.json b/config/ldp/modes/default.json index 1fa1e27ef..403f25bed 100644 --- a/config/ldp/modes/default.json +++ b/config/ldp/modes/default.json @@ -27,6 +27,7 @@ "source": { "@type": "WaterfallHandler", "handlers": [ + { "@type": "N3PatchModesExtractor" }, { "@type": "SparqlUpdateModesExtractor" }, { "@type": "StaticThrowHandler", diff --git a/config/storage/middleware/stores/patching.json b/config/storage/middleware/stores/patching.json index 033be5332..480253f91 100644 --- a/config/storage/middleware/stores/patching.json +++ b/config/storage/middleware/stores/patching.json @@ -14,11 +14,11 @@ { "comment": "Makes sure PATCH operations on containers target the metadata.", "@type": "ContainerPatcher", - "patcher": { "@type": "SparqlUpdatePatcher" } + "patcher": { "@id": "urn:solid-server:default:PatchHandler_RDF" } }, { "@type": "ConvertingPatcher", - "patcher": { "@type": "SparqlUpdatePatcher" }, + "patcher": { "@id": "urn:solid-server:default:PatchHandler_RDF" }, "converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, "intermediateType": "internal/quads", "defaultType": "text/turtle" @@ -30,6 +30,15 @@ ] } } + }, + { + "comment": "Dedicated handlers that apply specific types of patch documents", + "@id": "urn:solid-server:default:PatchHandler_RDF", + "@type": "WaterfallHandler", + "handlers": [ + { "@type": "N3Patcher" }, + { "@type": "SparqlUpdatePatcher" } + ] } ] } diff --git a/package-lock.json b/package-lock.json index 14c2c49a9..868c4afd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "punycode": "^2.1.1", "rdf-parse": "^1.8.1", "rdf-serialize": "^1.1.0", + "rdf-terms": "^1.7.1", "redis": "^3.1.2", "redlock": "^4.2.0", "sparqlalgebrajs": "^4.0.1", @@ -11360,11 +11361,6 @@ "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=", "dev": true }, - "node_modules/lodash.uniqwith": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniqwith/-/lodash.uniqwith-4.5.0.tgz", - "integrity": "sha1-egy/ZfQ7WShiWp1NDcVLGMrcfvM=" - }, "node_modules/logform": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.2.0.tgz", @@ -13542,13 +13538,13 @@ } }, "node_modules/rdf-terms": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.7.0.tgz", - "integrity": "sha512-K83ACD+MuWFS3mNxwCRNYQAmc/Z9iK7PgqJq9N4VP8sUVlP7ioB2pPNQHKHy0IQh4RTkEq6fg4R4q7YlweLBZQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.7.1.tgz", + "integrity": "sha512-zhYKqTrXTsoybs05Dpu1b+FDnS3+RsU4Fxsqj5aG7frPXDx0MMnIQOKUKpJL7KKYOtq/JE5JsLup6lggnxPqig==", "dependencies": { "@rdfjs/types": "*", - "lodash.uniqwith": "^4.5.0", - "rdf-data-factory": "^1.1.0" + "rdf-data-factory": "^1.1.0", + "rdf-string": "^1.6.0" } }, "node_modules/rdfa-streaming-parser": { @@ -24527,11 +24523,6 @@ "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=", "dev": true }, - "lodash.uniqwith": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniqwith/-/lodash.uniqwith-4.5.0.tgz", - "integrity": "sha1-egy/ZfQ7WShiWp1NDcVLGMrcfvM=" - }, "logform": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.2.0.tgz", @@ -26206,13 +26197,13 @@ } }, "rdf-terms": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.7.0.tgz", - "integrity": "sha512-K83ACD+MuWFS3mNxwCRNYQAmc/Z9iK7PgqJq9N4VP8sUVlP7ioB2pPNQHKHy0IQh4RTkEq6fg4R4q7YlweLBZQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.7.1.tgz", + "integrity": "sha512-zhYKqTrXTsoybs05Dpu1b+FDnS3+RsU4Fxsqj5aG7frPXDx0MMnIQOKUKpJL7KKYOtq/JE5JsLup6lggnxPqig==", "requires": { "@rdfjs/types": "*", - "lodash.uniqwith": "^4.5.0", - "rdf-data-factory": "^1.1.0" + "rdf-data-factory": "^1.1.0", + "rdf-string": "^1.6.0" } }, "rdfa-streaming-parser": { diff --git a/package.json b/package.json index 4580f267b..e43d29c5c 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "punycode": "^2.1.1", "rdf-parse": "^1.8.1", "rdf-serialize": "^1.1.0", + "rdf-terms": "^1.7.1", "redis": "^3.1.2", "redlock": "^4.2.0", "sparqlalgebrajs": "^4.0.1", diff --git a/src/authorization/permissions/N3PatchModesExtractor.ts b/src/authorization/permissions/N3PatchModesExtractor.ts new file mode 100644 index 000000000..bc4d8a2cc --- /dev/null +++ b/src/authorization/permissions/N3PatchModesExtractor.ts @@ -0,0 +1,44 @@ +import type { Operation } from '../../http/Operation'; +import type { N3Patch } from '../../http/representation/N3Patch'; +import { isN3Patch } from '../../http/representation/N3Patch'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { ModesExtractor } from './ModesExtractor'; +import { AccessMode } from './Permissions'; + +/** + * Extracts the required access modes from an N3 Patch. + * + * Solid, §5.3.1: "When ?conditions is non-empty, servers MUST treat the request as a Read operation. + * When ?insertions is non-empty, servers MUST (also) treat the request as an Append operation. + * When ?deletions is non-empty, servers MUST treat the request as a Read and Write operation." + * https://solid.github.io/specification/protocol#n3-patch + */ +export class N3PatchModesExtractor extends ModesExtractor { + public async canHandle({ body }: Operation): Promise { + if (!isN3Patch(body)) { + throw new NotImplementedHttpError('Can only determine permissions of N3 Patch documents.'); + } + } + + public async handle({ body }: Operation): Promise> { + const { deletes, inserts, conditions } = body as N3Patch; + + const accessModes = new Set(); + + // When ?conditions is non-empty, servers MUST treat the request as a Read operation. + if (conditions.length > 0) { + accessModes.add(AccessMode.read); + } + // When ?insertions is non-empty, servers MUST (also) treat the request as an Append operation. + if (inserts.length > 0) { + accessModes.add(AccessMode.append); + } + // When ?deletions is non-empty, servers MUST treat the request as a Read and Write operation. + if (deletes.length > 0) { + accessModes.add(AccessMode.read); + accessModes.add(AccessMode.write); + } + + return accessModes; + } +} diff --git a/src/http/input/body/N3PatchBodyParser.ts b/src/http/input/body/N3PatchBodyParser.ts new file mode 100644 index 000000000..d6a1ccfd0 --- /dev/null +++ b/src/http/input/body/N3PatchBodyParser.ts @@ -0,0 +1,138 @@ +import type { NamedNode, Quad, Quad_Subject, Variable } from '@rdfjs/types'; +import { DataFactory, Parser, Store } from 'n3'; +import { getBlankNodes, getTerms, getVariables } from 'rdf-terms'; +import { TEXT_N3 } from '../../../util/ContentTypes'; +import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; +import { createErrorMessage } from '../../../util/errors/ErrorUtil'; +import { UnprocessableEntityHttpError } from '../../../util/errors/UnprocessableEntityHttpError'; +import { UnsupportedMediaTypeHttpError } from '../../../util/errors/UnsupportedMediaTypeHttpError'; +import { guardedStreamFrom, readableToString } from '../../../util/StreamUtil'; +import { RDF, SOLID } from '../../../util/Vocabularies'; +import type { N3Patch } from '../../representation/N3Patch'; +import type { BodyParserArgs } from './BodyParser'; +import { BodyParser } from './BodyParser'; + +const defaultGraph = DataFactory.defaultGraph(); + +/** + * Parses an N3 Patch document and makes sure it conforms to the specification requirements. + * Requirements can be found at Solid Protocol, §5.3.1: https://solid.github.io/specification/protocol#n3-patch + */ +export class N3PatchBodyParser extends BodyParser { + public async canHandle({ metadata }: BodyParserArgs): Promise { + if (metadata.contentType !== TEXT_N3) { + throw new UnsupportedMediaTypeHttpError('This parser only supports N3 Patch documents.'); + } + } + + public async handle({ request, metadata }: BodyParserArgs): Promise { + const n3 = await readableToString(request); + const parser = new Parser({ format: TEXT_N3, baseIRI: metadata.identifier.value }); + let store: Store; + try { + store = new Store(parser.parse(n3)); + } catch (error: unknown) { + throw new BadRequestHttpError(`Invalid N3: ${createErrorMessage(error)}`); + } + + // Solid, §5.3.1: "A patch resource MUST contain a triple ?patch rdf:type solid:InsertDeletePatch." + // "The patch document MUST contain exactly one patch resource, + // identified by one or more of the triple patterns described above, which all share the same ?patch subject." + const patches = store.getSubjects(RDF.terms.type, SOLID.terms.InsertDeletePatch, defaultGraph); + if (patches.length !== 1) { + throw new UnprocessableEntityHttpError( + `This patcher only supports N3 Patch documents with exactly 1 solid:InsertDeletePatch entry, but received ${ + patches.length}.`, + ); + } + return { + ...this.parsePatch(patches[0], store), + binary: true, + data: guardedStreamFrom(n3), + metadata, + isEmpty: false, + }; + } + + /** + * Extracts the deletes/inserts/conditions from a solid:InsertDeletePatch entry. + */ + private parsePatch(patch: Quad_Subject, store: Store): { deletes: Quad[]; inserts: Quad[]; conditions: Quad[] } { + // Solid, §5.3.1: "A patch resource MUST be identified by a URI or blank node, which we refer to as ?patch + // in the remainder of this section." + if (patch.termType !== 'NamedNode' && patch.termType !== 'BlankNode') { + throw new UnprocessableEntityHttpError('An N3 Patch subject needs to be a blank or named node.'); + } + + // Extract all quads from the corresponding formulae + const deletes = this.findQuads(store, patch, SOLID.terms.deletes); + const inserts = this.findQuads(store, patch, SOLID.terms.inserts); + const conditions = this.findQuads(store, patch, SOLID.terms.where); + + // Make sure there are no forbidden combinations + const conditionVars = this.findVariables(conditions); + this.verifyQuads(deletes, conditionVars); + this.verifyQuads(inserts, conditionVars); + + return { deletes, inserts, conditions }; + } + + /** + * Finds all quads in a where/deletes/inserts formula. + * The returned quads will be updated so their graph is the default graph instead of the N3 reference to the formula. + * Will error in case there are multiple instances of the subject/predicate combination. + */ + private findQuads(store: Store, subject: Quad_Subject, predicate: NamedNode): Quad[] { + const graphs = store.getObjects(subject, predicate, defaultGraph); + if (graphs.length > 1) { + throw new UnprocessableEntityHttpError(`An N3 Patch can have at most 1 ${predicate.value}.`); + } + if (graphs.length === 0) { + return []; + } + // This might not return all quads in case of nested formulae, + // but these are not allowed and will throw an error later when checking for blank nodes. + // Another check would be needed in case blank nodes are allowed in the future. + const quads: Quad[] = store.getQuads(null, null, null, graphs[0]); + + // Remove the graph references so they can be interpreted as standard triples + // independent of the formula they were in. + return quads.map((quad): Quad => DataFactory.quad(quad.subject, quad.predicate, quad.object, defaultGraph)); + } + + /** + * Finds all variables in a set of quads. + */ + private findVariables(quads: Quad[]): Set { + return new Set( + quads.flatMap((quad): Variable[] => getVariables(getTerms(quad))) + .map((variable): string => variable.value), + ); + } + + /** + * Verifies if the delete/insert triples conform to the specification requirements: + * - They should not contain blank nodes. + * - They should not contain variables that do not occur in the conditions. + */ + private verifyQuads(otherQuads: Quad[], conditionVars: Set): void { + for (const quad of otherQuads) { + const terms = getTerms(quad); + const blankNodes = getBlankNodes(terms); + // Solid, §5.3.1: "The ?insertions and ?deletions formulae MUST NOT contain blank nodes." + if (blankNodes.length > 0) { + throw new UnprocessableEntityHttpError(`An N3 Patch delete/insert formula can not contain blank nodes.`); + } + const variables = getVariables(terms); + for (const variable of variables) { + // Solid, §5.3.1: "The ?insertions and ?deletions formulae + // MUST NOT contain variables that do not occur in the ?conditions formula." + if (!conditionVars.has(variable.value)) { + throw new UnprocessableEntityHttpError( + `An N3 Patch delete/insert formula can only contain variables found in the conditions formula.`, + ); + } + } + } + } +} diff --git a/src/http/representation/N3Patch.ts b/src/http/representation/N3Patch.ts new file mode 100644 index 000000000..80ab1c6e8 --- /dev/null +++ b/src/http/representation/N3Patch.ts @@ -0,0 +1,18 @@ +import type { Quad } from 'rdf-js'; +import type { Patch } from './Patch'; + +/** + * A Representation of an N3 Patch. + * All quads should be in the default graph. + */ +export interface N3Patch extends Patch { + deletes: Quad[]; + inserts: Quad[]; + conditions: Quad[]; +} + +export function isN3Patch(patch: unknown): patch is N3Patch { + return Array.isArray((patch as N3Patch).deletes) && + Array.isArray((patch as N3Patch).inserts) && + Array.isArray((patch as N3Patch).conditions); +} diff --git a/src/index.ts b/src/index.ts index cd70639ea..498bd2212 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ export * from './authorization/access/AgentGroupAccessChecker'; export * from './authorization/permissions/Permissions'; export * from './authorization/permissions/ModesExtractor'; export * from './authorization/permissions/MethodModesExtractor'; +export * from './authorization/permissions/N3PatchModesExtractor'; export * from './authorization/permissions/SparqlUpdateModesExtractor'; // Authorization @@ -45,6 +46,7 @@ export * from './http/auxiliary/Validator'; // HTTP/Input/Body export * from './http/input/body/BodyParser'; +export * from './http/input/body/N3PatchBodyParser'; export * from './http/input/body/RawBodyParser'; export * from './http/input/body/SparqlUpdateBodyParser'; @@ -295,6 +297,7 @@ export * from './storage/mapping/SubdomainExtensionBasedMapper'; // Storage/Patch export * from './storage/patch/ContainerPatcher'; export * from './storage/patch/ConvertingPatcher'; +export * from './storage/patch/N3Patcher'; export * from './storage/patch/PatchHandler'; export * from './storage/patch/RepresentationPatcher'; export * from './storage/patch/RepresentationPatchHandler'; diff --git a/src/storage/patch/N3Patcher.ts b/src/storage/patch/N3Patcher.ts new file mode 100644 index 000000000..ac4f0a9df --- /dev/null +++ b/src/storage/patch/N3Patcher.ts @@ -0,0 +1,160 @@ +import type { Readable } from 'stream'; +import { newEngine } from '@comunica/actor-init-sparql'; +import type { ActorInitSparql } from '@comunica/actor-init-sparql'; +import type { IQueryResultBindings } from '@comunica/actor-init-sparql/lib/ActorInitSparql-browser'; +import { Store } from 'n3'; +import type { Quad, Term } from 'rdf-js'; +import { mapTerms } from 'rdf-terms'; +import { Generator, Wildcard } from 'sparqljs'; +import type { SparqlGenerator } from 'sparqljs'; +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import { isN3Patch } from '../../http/representation/N3Patch'; +import type { N3Patch } from '../../http/representation/N3Patch'; +import type { Representation } from '../../http/representation/Representation'; +import { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import { getLoggerFor } from '../../logging/LogUtil'; +import { INTERNAL_QUADS } from '../../util/ContentTypes'; +import { ConflictHttpError } from '../../util/errors/ConflictHttpError'; +import { InternalServerError } from '../../util/errors/InternalServerError'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { uniqueQuads } from '../../util/QuadUtil'; +import { readableToQuads } from '../../util/StreamUtil'; +import type { RepresentationPatcherInput } from './RepresentationPatcher'; +import { RepresentationPatcher } from './RepresentationPatcher'; + +/** + * Applies an N3 Patch to a representation, or creates a new one if required. + * Follows all the steps from Solid, §5.3.1: https://solid.github.io/specification/protocol#n3-patch + */ +export class N3Patcher extends RepresentationPatcher { + protected readonly logger = getLoggerFor(this); + + private readonly engine: ActorInitSparql; + private readonly generator: SparqlGenerator; + + public constructor() { + super(); + this.engine = newEngine(); + this.generator = new Generator(); + } + + public async canHandle({ patch }: RepresentationPatcherInput): Promise { + if (!isN3Patch(patch)) { + throw new NotImplementedHttpError('Only N3 Patch updates are supported'); + } + } + + public async handle(input: RepresentationPatcherInput): Promise { + const patch = input.patch as N3Patch; + + // No work to be done if the patch is empty + if (patch.deletes.length === 0 && patch.inserts.length === 0 && patch.conditions.length === 0) { + this.logger.debug('Empty patch, returning input.'); + return input.representation ?? new BasicRepresentation([], input.identifier, INTERNAL_QUADS, false); + } + + if (input.representation && input.representation.metadata.contentType !== INTERNAL_QUADS) { + this.logger.error('Received non-quad data. This should not happen so there is probably a configuration error.'); + throw new InternalServerError('Quad stream was expected for patching.'); + } + + return this.patch(input); + } + + /** + * Applies the given N3Patch to the representation. + * First the conditions are applied to find the necessary bindings, + * which are then applied to generate the triples that need to be deleted and inserted. + * After that the delete and insert operations are applied. + */ + private async patch({ identifier, patch, representation }: RepresentationPatcherInput): Promise { + const result = representation ? await readableToQuads(representation.data) : new Store(); + this.logger.debug(`${result.size} quads in ${identifier.path}.`); + + const { deletes, inserts } = await this.applyConditions(patch as N3Patch, identifier, result); + + // Apply deletes + if (deletes.length > 0) { + // There could potentially be duplicates after applying conditions, + // which would result in an incorrect count. + const uniqueDeletes = uniqueQuads(deletes); + // Solid, §5.3.1: "The triples resulting from ?deletions are to be removed from the RDF dataset." + const oldSize = result.size; + result.removeQuads(uniqueDeletes); + + // Solid, §5.3.1: "If the set of triples resulting from ?deletions is non-empty and the dataset + // does not contain all of these triples, the server MUST respond with a 409 status code." + if (oldSize - result.size !== uniqueDeletes.length) { + throw new ConflictHttpError( + 'The document does not contain all triples the N3 Patch requests to delete, which is required for patching.', + ); + } + this.logger.debug(`Deleted ${oldSize - result.size} quads from ${identifier.path}.`); + } + + // Solid, §5.3.1: "The triples resulting from ?insertions are to be added to the RDF dataset, + // with each blank node from ?insertions resulting in a newly created blank node." + result.addQuads(inserts); + + this.logger.debug(`${result.size} total quads after patching ${identifier.path}.`); + + const metadata = representation?.metadata ?? new RepresentationMetadata(identifier, INTERNAL_QUADS); + return new BasicRepresentation(result.match() as unknown as Readable, metadata, false); + } + + /** + * Creates a new N3Patch where the conditions of the provided patch parameter are applied to its deletes and inserts. + * Also does the necessary checks to make sure the conditions are valid for the given dataset. + */ + private async applyConditions(patch: N3Patch, identifier: ResourceIdentifier, source: Store): Promise { + const { conditions } = patch; + let { deletes, inserts } = patch; + + if (conditions.length > 0) { + // Solid, §5.3.1: "If ?conditions is non-empty, find all (possibly empty) variable mappings + // such that all of the resulting triples occur in the dataset." + const sparql = this.generator.stringify({ + type: 'query', + queryType: 'SELECT', + variables: [ new Wildcard() ], + prefixes: {}, + where: [{ + type: 'bgp', + triples: conditions, + }], + }); + this.logger.debug(`Finding bindings using SPARQL query ${sparql}`); + const query = await this.engine.query(sparql, + { sources: [ source ], baseIRI: identifier.path }) as IQueryResultBindings; + const bindings = await query.bindings(); + + // Solid, §5.3.1: "If no such mapping exists, or if multiple mappings exist, + // the server MUST respond with a 409 status code." + if (bindings.length === 0) { + throw new ConflictHttpError( + 'The document does not contain any matches for the N3 Patch solid:where condition.', + ); + } + if (bindings.length > 1) { + throw new ConflictHttpError( + 'The document contains multiple matches for the N3 Patch solid:where condition, which is not allowed.', + ); + } + + // Apply bindings to deletes/inserts + // Note that Comunica binding keys start with a `?` while Variable terms omit that in their value + deletes = deletes.map((quad): Quad => mapTerms(quad, (term): Term => + term.termType === 'Variable' ? bindings[0].get(`?${term.value}`) : term)); + inserts = inserts.map((quad): Quad => mapTerms(quad, (term): Term => + term.termType === 'Variable' ? bindings[0].get(`?${term.value}`) : term)); + } + + return { + ...patch, + deletes, + inserts, + conditions: [], + }; + } +} diff --git a/src/storage/patch/SparqlUpdatePatcher.ts b/src/storage/patch/SparqlUpdatePatcher.ts index 92d707b28..735b39eb7 100644 --- a/src/storage/patch/SparqlUpdatePatcher.ts +++ b/src/storage/patch/SparqlUpdatePatcher.ts @@ -4,7 +4,6 @@ import { newEngine } from '@comunica/actor-init-sparql'; import type { IQueryResultUpdate } from '@comunica/actor-init-sparql/lib/ActorInitSparql-browser'; import { defaultGraph } from '@rdfjs/data-model'; import { Store } from 'n3'; -import type { BaseQuad } from 'rdf-js'; import { Algebra } from 'sparqlalgebrajs'; import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; import type { Patch } from '../../http/representation/Patch'; @@ -50,6 +49,10 @@ export class SparqlUpdatePatcher extends RepresentationPatcher { return representation ?? new BasicRepresentation([], identifier, INTERNAL_QUADS, false); } + if (representation && representation.metadata.contentType !== INTERNAL_QUADS) { + throw new InternalServerError('Quad stream was expected for patching.'); + } + this.validateUpdate(op); return this.patch(input); @@ -116,20 +119,8 @@ export class SparqlUpdatePatcher extends RepresentationPatcher { * Apply the given algebra operation to the given identifier. */ private async patch({ identifier, patch, representation }: RepresentationPatcherInput): Promise { - let result: Store; - let metadata: RepresentationMetadata; - - if (representation) { - ({ metadata } = representation); - if (metadata.contentType !== INTERNAL_QUADS) { - throw new InternalServerError('Quad stream was expected for patching.'); - } - result = await readableToQuads(representation.data); - this.logger.debug(`${result.size} quads in ${identifier.path}.`); - } else { - metadata = new RepresentationMetadata(identifier, INTERNAL_QUADS); - result = new Store(); - } + const result = representation ? await readableToQuads(representation.data) : new Store(); + this.logger.debug(`${result.size} quads in ${identifier.path}.`); // Run the query through Comunica const sparql = await readableToString(patch.data); @@ -139,6 +130,7 @@ export class SparqlUpdatePatcher extends RepresentationPatcher { this.logger.debug(`${result.size} quads will be stored to ${identifier.path}.`); + const metadata = representation?.metadata ?? new RepresentationMetadata(identifier, INTERNAL_QUADS); return new BasicRepresentation(result.match() as unknown as Readable, metadata, false); } } diff --git a/src/util/ContentTypes.ts b/src/util/ContentTypes.ts index e45cccca8..47481171d 100644 --- a/src/util/ContentTypes.ts +++ b/src/util/ContentTypes.ts @@ -5,6 +5,7 @@ export const APPLICATION_SPARQL_UPDATE = 'application/sparql-update'; export const APPLICATION_X_WWW_FORM_URLENCODED = 'application/x-www-form-urlencoded'; export const TEXT_HTML = 'text/html'; export const TEXT_MARKDOWN = 'text/markdown'; +export const TEXT_N3 = 'text/n3'; export const TEXT_TURTLE = 'text/turtle'; // Internal content types (not exposed over HTTP) diff --git a/src/util/QuadUtil.ts b/src/util/QuadUtil.ts index eb9b81f9d..9f50029a7 100644 --- a/src/util/QuadUtil.ts +++ b/src/util/QuadUtil.ts @@ -6,6 +6,13 @@ import type { Quad } from 'rdf-js'; import type { Guarded } from './GuardedStream'; import { guardedStreamFrom, pipeSafely } from './StreamUtil'; +/** + * Helper function for serializing an array of quads, with as result a Readable object. + * @param quads - The array of quads. + * @param contentType - The content-type to serialize to. + * + * @returns The Readable object. + */ export function serializeQuads(quads: Quad[], contentType?: string): Guarded { return pipeSafely(guardedStreamFrom(quads), new StreamWriter({ format: contentType })); } @@ -20,3 +27,18 @@ export function serializeQuads(quads: Quad[], contentType?: string): Guarded, options: ParserOptions = {}): Promise { return arrayifyStream(pipeSafely(readable, new StreamParser(options))); } + +/** + * Filter out duplicate quads from an array. + * @param quads - Quads to filter. + * + * @returns A new array containing the unique quads. + */ +export function uniqueQuads(quads: Quad[]): Quad[] { + return quads.reduce((result, quad): Quad[] => { + if (!result.some((item): boolean => quad.equals(item))) { + result.push(quad); + } + return result; + }, []); +} diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index 32384d9b7..7742949f8 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -124,9 +124,14 @@ export const RDF = createUriAndTermNamespace('http://www.w3.org/1999/02/22-rdf-s ); export const SOLID = createUriAndTermNamespace('http://www.w3.org/ns/solid/terms#', + 'deletes', + 'inserts', 'oidcIssuer', 'oidcIssuerRegistrationToken', 'oidcRegistration', + 'where', + + 'InsertDeletePatch', ); export const SOLID_ERROR = createUriAndTermNamespace('urn:npm:solid:community-server:error:', diff --git a/src/util/errors/UnprocessableEntityHttpError.ts b/src/util/errors/UnprocessableEntityHttpError.ts new file mode 100644 index 000000000..1ff8c039a --- /dev/null +++ b/src/util/errors/UnprocessableEntityHttpError.ts @@ -0,0 +1,15 @@ +import type { HttpErrorOptions } from './HttpError'; +import { HttpError } from './HttpError'; + +/** + * An error thrown when the server understands the content-type but can't process the instructions. + */ +export class UnprocessableEntityHttpError extends HttpError { + public constructor(message?: string, options?: HttpErrorOptions) { + super(422, 'UnprocessableEntityHttpError', message, options); + } + + public static isInstance(error: any): error is UnprocessableEntityHttpError { + return HttpError.isInstance(error) && error.statusCode === 422; + } +} diff --git a/src/util/handlers/MethodFilterHandler.ts b/src/util/handlers/MethodFilterHandler.ts index a358547e2..e0c5814c6 100644 --- a/src/util/handlers/MethodFilterHandler.ts +++ b/src/util/handlers/MethodFilterHandler.ts @@ -1,11 +1,15 @@ import { NotImplementedHttpError } from '../errors/NotImplementedHttpError'; import { AsyncHandler } from './AsyncHandler'; +// The formats from which we can detect the method +type InType = { method: string } | { request: { method: string }} | { operation: { method: string }}; + /** - * Only accepts requests where the input has a `method` field that matches any one of the given methods. + * Only accepts requests where the input has a (possibly nested) `method` field + * that matches any one of the given methods. * In case of a match, the input will be sent to the source handler. */ -export class MethodFilterHandler extends AsyncHandler { +export class MethodFilterHandler extends AsyncHandler { private readonly methods: string[]; private readonly source: AsyncHandler; @@ -16,9 +20,10 @@ export class MethodFilterHandler extends A } public async canHandle(input: TIn): Promise { - if (!this.methods.includes(input.method)) { + const method = this.findMethod(input); + if (!this.methods.includes(method)) { throw new NotImplementedHttpError( - `Cannot determine permissions of ${input.method}, only ${this.methods.join(',')}.`, + `Cannot determine permissions of ${method}, only ${this.methods.join(',')}.`, ); } await this.source.canHandle(input); @@ -27,4 +32,20 @@ export class MethodFilterHandler extends A public async handle(input: TIn): Promise { return this.source.handle(input); } + + /** + * Finds the correct method in the input object. + */ + private findMethod(input: InType): string { + if ('method' in input) { + return input.method; + } + if ('request' in input) { + return this.findMethod(input.request); + } + if ('operation' in input) { + return this.findMethod(input.operation); + } + throw new NotImplementedHttpError('Could not find method in input object.'); + } } diff --git a/test/.eslintrc.js b/test/.eslintrc.js index 585742262..58ba353d6 100644 --- a/test/.eslintrc.js +++ b/test/.eslintrc.js @@ -11,6 +11,9 @@ module.exports = { 'unicorn/no-useless-undefined': 'off', 'no-process-env': 'off', + // Rule is not smart enough to check called function in the test + 'jest/expect-expect': 'off', + // We are not using Mocha 'mocha/no-exports': 'off', 'mocha/no-nested-tests': 'off', diff --git a/test/integration/N3Patch.test.ts b/test/integration/N3Patch.test.ts new file mode 100644 index 000000000..21281c91a --- /dev/null +++ b/test/integration/N3Patch.test.ts @@ -0,0 +1,398 @@ +import 'jest-rdf'; +import { fetch } from 'cross-fetch'; +import { Parser } from 'n3'; +import type { AclPermission } from '../../src/authorization/permissions/AclPermission'; +import { BasicRepresentation } from '../../src/http/representation/BasicRepresentation'; +import type { App } from '../../src/init/App'; +import type { ResourceStore } from '../../src/storage/ResourceStore'; +import { joinUrl } from '../../src/util/PathUtil'; +import { AclHelper } from '../util/AclHelper'; +import { getPort } from '../util/Util'; +import { + getDefaultVariables, + getPresetConfigPath, + getTestConfigPath, + instantiateFromConfig, +} from './Config'; + +const port = getPort('N3Patch'); +const baseUrl = `http://localhost:${port}/`; + +let store: ResourceStore; +let aclHelper: AclHelper; + +async function expectPatch( + input: { path: string; contentType?: string; body: string }, + expected: { status: number; message?: string; turtle?: string }, +): Promise { + const message = expected.message ?? ''; + const contentType = input.contentType ?? 'text/n3'; + + const body = `@prefix solid: . + ${input.body}`; + + const url = joinUrl(baseUrl, input.path); + const res = await fetch(url, { + method: 'PATCH', + headers: { 'content-type': contentType }, + body, + }); + await expect(res.text()).resolves.toContain(message); + expect(res.status).toBe(expected.status); + + // Verify if the resource has the expected RDF data + if (expected.turtle) { + // Might not have read permissions so need to update + await aclHelper.setSimpleAcl(url, { permissions: { read: true }, agentClass: 'agent', accessTo: true }); + const get = await fetch(url, { + method: 'GET', + headers: { accept: 'text/turtle' }, + }); + const expectedTurtle = `@prefix solid: . + ${expected.turtle}`; + + expect(get.status).toBe(200); + const parser = new Parser({ format: 'text/turtle', baseIRI: url }); + const actualTriples = parser.parse(await get.text()); + expect(actualTriples).toBeRdfIsomorphic(parser.parse(expectedTurtle)); + } +} + +// Creates/updates a resource with the given data and permissions +async function setResource(path: string, turtle: string, permissions: AclPermission): Promise { + const url = joinUrl(baseUrl, path); + await store.setRepresentation({ path: url }, new BasicRepresentation(turtle, 'text/turtle')); + await aclHelper.setSimpleAcl(url, { permissions, agentClass: 'agent', accessTo: true }); +} + +describe('A Server supporting N3 Patch', (): void => { + let app: App; + + beforeAll(async(): Promise => { + // Create and start the server + const instances = await instantiateFromConfig( + 'urn:solid-server:test:Instances', + [ + getPresetConfigPath('storage/backend/memory.json'), + getTestConfigPath('ldp-with-auth.json'), + ], + getDefaultVariables(port, baseUrl), + ) as Record; + ({ app, store } = instances); + + await app.start(); + + // Create test helper for manipulating acl + aclHelper = new AclHelper(store); + }); + + afterAll(async(): Promise => { + await app.stop(); + }); + + describe('with an invalid patch document', (): void => { + it('requires text/n3 content-type.', async(): Promise => { + await expectPatch( + { path: '/invalid', contentType: 'text/other', body: '' }, + { status: 415 }, + ); + }); + + it('requires valid syntax.', async(): Promise => { + await expectPatch( + { path: '/invalid', body: 'invalid syntax' }, + { status: 400, message: 'Invalid N3' }, + ); + }); + + it('requires a solid:InsertDeletePatch.', async(): Promise => { + await expectPatch( + { path: '/invalid', body: '<> a solid:Patch.' }, + { + status: 422, + message: 'This patcher only supports N3 Patch documents with exactly 1 solid:InsertDeletePatch entry', + }, + ); + }); + }); + + describe('inserting data', (): void => { + it('succeeds if there is no resource.', async(): Promise => { + await expectPatch( + { path: '/new-insert', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, + { status: 201, turtle: ' .' }, + ); + }); + + it('fails if there is only read access.', async(): Promise => { + await setResource('/read-only', ' .', { read: true }); + await expectPatch( + { path: '/read-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, + { status: 401 }, + ); + }); + + it('succeeds if there is only read access.', async(): Promise => { + await setResource('/append-only', ' .', { append: true }); + await expectPatch( + { path: '/append-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, + { status: 205, turtle: ' . .' }, + ); + }); + + it('succeeds if there is only write access.', async(): Promise => { + await setResource('/write-only', ' .', { write: true }); + await expectPatch( + { path: '/write-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, + { status: 205, turtle: ' . .' }, + ); + }); + }); + + describe('inserting conditional data', (): void => { + it('fails if there is no resource.', async(): Promise => { + await expectPatch( + { path: '/new-insert-where', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' }, + ); + }); + + it('fails if there is only read access.', async(): Promise => { + await setResource('/read-only', ' .', { read: true }); + await expectPatch( + { path: '/read-only', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only append access.', async(): Promise => { + await setResource('/append-only', ' .', { append: true }); + await expectPatch( + { path: '/append-only', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only write access.', async(): Promise => { + await setResource('/write-only', ' .', { write: true }); + await expectPatch( + { path: '/write-only', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 401 }, + ); + }); + + describe('with read/append access', (): void => { + it('succeeds if the conditions match.', async(): Promise => { + await setResource('/read-append', ' .', { read: true, append: true }); + await expectPatch( + { path: '/read-append', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 205, turtle: ' . .' }, + ); + }); + + it('rejects if there is no match.', async(): Promise => { + await setResource('/read-append', ' .', { read: true, append: true }); + await expectPatch( + { path: '/read-append', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' }, + ); + }); + + it('rejects if there are multiple matches.', async(): Promise => { + await setResource('/read-append', ' . .', { read: true, append: true }); + await expectPatch( + { path: '/read-append', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 409, message: 'The document contains multiple matches for the N3 Patch solid:where condition' }, + ); + }); + }); + + describe('with read/write access', (): void => { + it('succeeds if the conditions match.', async(): Promise => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` }, + { status: 205, turtle: ' . .' }, + ); + }); + }); + }); + + describe('deleting data', (): void => { + it('fails if there is no resource.', async(): Promise => { + await expectPatch( + { path: '/new-delete', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' }, + ); + }); + + it('fails if there is only append access.', async(): Promise => { + await setResource('/append-only', ' .', { append: true }); + await expectPatch( + { path: '/append-only', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only write access.', async(): Promise => { + await setResource('/write-only', ' .', { write: true }); + await expectPatch( + { path: '/write-only', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only read/append access.', async(): Promise => { + await setResource('/read-append', ' .', { read: true, append: true }); + await expectPatch( + { path: '/read-append', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + describe('with read/write access', (): void => { + it('succeeds if the delete triples exist.', async(): Promise => { + await setResource('/read-write', ' . .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { status: 205, turtle: ' .' }, + ); + }); + + it('fails if the delete triples do not exist.', async(): Promise => { + await setResource('/read-write', ' . .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` }, + { status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' }, + ); + }); + + it('succeeds if the conditions match.', async(): Promise => { + await setResource('/read-write', ' . .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:deletes { ?a . }.` }, + { status: 205, turtle: ' .' }, + ); + }); + + it('fails if the conditions do not match.', async(): Promise => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:deletes { ?a . }.` }, + { status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' }, + ); + }); + }); + }); + + describe('deleting and inserting data', (): void => { + it('fails if there is no resource.', async(): Promise => { + await expectPatch( + { path: '/new-delete-insert', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' }, + ); + }); + + it('fails if there is only append access.', async(): Promise => { + await setResource('/append-only', ' .', { append: true }); + await expectPatch( + { path: '/append-only', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only write access.', async(): Promise => { + await setResource('/write-only', ' .', { write: true }); + await expectPatch( + { path: '/write-only', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + it('fails if there is only read/append access.', async(): Promise => { + await setResource('/read-append', ' .', { read: true, append: true }); + await expectPatch( + { path: '/read-append', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { status: 401 }, + ); + }); + + describe('with read/write access', (): void => { + it('executes deletes before inserts.', async(): Promise => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' }, + ); + }); + + it('succeeds if the delete triples exist.', async(): Promise => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` }, + { status: 205, turtle: ' .' }, + ); + }); + + it('succeeds if the conditions match.', async(): Promise => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:inserts { ?a . }; + solid:deletes { ?a . }.` }, + { status: 205, turtle: ' .' }, + ); + }); + + it('fails if the conditions do not match.', async(): Promise => { + await setResource('/read-write', ' .', { read: true, write: true }); + await expectPatch( + { path: '/read-write', body: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:inserts { ?a . }; + solid:deletes { ?a . }.` }, + { status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' }, + ); + }); + }); + }); +}); diff --git a/test/unit/authorization/permissions/N3PatchModesExtractor.test.ts b/test/unit/authorization/permissions/N3PatchModesExtractor.test.ts new file mode 100644 index 000000000..7b4688858 --- /dev/null +++ b/test/unit/authorization/permissions/N3PatchModesExtractor.test.ts @@ -0,0 +1,62 @@ +import { DataFactory } from 'n3'; +import type { Quad } from 'rdf-js'; +import { N3PatchModesExtractor } from '../../../../src/authorization/permissions/N3PatchModesExtractor'; +import { AccessMode } from '../../../../src/authorization/permissions/Permissions'; +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { N3Patch } from '../../../../src/http/representation/N3Patch'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; + +const { quad, namedNode } = DataFactory; + +describe('An N3PatchModesExtractor', (): void => { + const triple: Quad = quad(namedNode('a'), namedNode('b'), namedNode('c')); + let patch: N3Patch; + let operation: Operation; + const extractor = new N3PatchModesExtractor(); + + beforeEach(async(): Promise => { + patch = new BasicRepresentation() as N3Patch; + patch.deletes = []; + patch.inserts = []; + patch.conditions = []; + + operation = { + method: 'PATCH', + body: patch, + preferences: {}, + target: { path: 'http://example.com/foo' }, + }; + }); + + it('can only handle N3 Patch documents.', async(): Promise => { + operation.body = new BasicRepresentation(); + await expect(extractor.canHandle(operation)).rejects.toThrow(NotImplementedHttpError); + + operation.body = patch; + await expect(extractor.canHandle(operation)).resolves.toBeUndefined(); + }); + + it('requires read access when there are conditions.', async(): Promise => { + patch.conditions = [ triple ]; + await expect(extractor.handle(operation)).resolves.toEqual(new Set([ AccessMode.read ])); + }); + + it('requires append access when there are inserts.', async(): Promise => { + patch.inserts = [ triple ]; + await expect(extractor.handle(operation)).resolves.toEqual(new Set([ AccessMode.append ])); + }); + + it('requires read and write access when there are inserts.', async(): Promise => { + patch.deletes = [ triple ]; + await expect(extractor.handle(operation)).resolves.toEqual(new Set([ AccessMode.read, AccessMode.write ])); + }); + + it('combines required access modes when required.', async(): Promise => { + patch.conditions = [ triple ]; + patch.inserts = [ triple ]; + patch.deletes = [ triple ]; + await expect(extractor.handle(operation)).resolves + .toEqual(new Set([ AccessMode.read, AccessMode.append, AccessMode.write ])); + }); +}); diff --git a/test/unit/http/input/body/N3PatchBodyParser.test.ts b/test/unit/http/input/body/N3PatchBodyParser.test.ts new file mode 100644 index 000000000..902baec41 --- /dev/null +++ b/test/unit/http/input/body/N3PatchBodyParser.test.ts @@ -0,0 +1,204 @@ +import 'jest-rdf'; +import type { Term } from '@rdfjs/types'; +import { DataFactory } from 'n3'; +import type { BodyParserArgs } from '../../../../../src/http/input/body/BodyParser'; +import { N3PatchBodyParser } from '../../../../../src/http/input/body/N3PatchBodyParser'; +import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata'; +import type { HttpRequest } from '../../../../../src/server/HttpRequest'; +import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; +import { UnsupportedMediaTypeHttpError } from '../../../../../src/util/errors/UnsupportedMediaTypeHttpError'; +import { guardedStreamFrom } from '../../../../../src/util/StreamUtil'; +const { defaultGraph, literal, namedNode, quad, variable } = DataFactory; + +describe('An N3PatchBodyParser', (): void => { + let input: BodyParserArgs; + const parser = new N3PatchBodyParser(); + + beforeEach(async(): Promise => { + input = { + request: { headers: {}} as HttpRequest, + metadata: new RepresentationMetadata({ path: 'http://example.com/foo' }, 'text/n3'), + }; + }); + + it('can only handle N3 data.', async(): Promise => { + input.metadata.contentType = 'text/plain'; + await expect(parser.canHandle(input)).rejects.toThrow(UnsupportedMediaTypeHttpError); + input.metadata.contentType = 'text/n3'; + await expect(parser.canHandle(input)).resolves.toBeUndefined(); + }); + + it('errors on invalid N3.', async(): Promise => { + input.request = guardedStreamFrom([ 'invalid syntax' ]) as HttpRequest; + await expect(parser.handle(input)).rejects.toThrow(BadRequestHttpError); + }); + + it('extracts the patch quads from the request.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { ?person ex:givenName "Alex". }; + solid:deletes { ?person ex:givenName "Claudia". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + const patch = await parser.handle(input); + expect(patch.conditions).toBeRdfIsomorphic([ + quad(variable('person'), namedNode('http://www.example.org/terms#familyName'), literal('Garcia')), + quad(variable('person'), namedNode('http://www.example.org/terms#nickName'), literal('Garry')), + ]); + expect(patch.inserts).toBeRdfIsomorphic([ + quad(variable('person'), namedNode('http://www.example.org/terms#givenName'), literal('Alex')), + ]); + expect(patch.deletes).toBeRdfIsomorphic([ + quad(variable('person'), namedNode('http://www.example.org/terms#givenName'), literal('Claudia')), + ]); + }); + + it('strips the graph from the result quads.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { ?person ex:givenName "Alex". }; + solid:deletes { ?person ex:givenName "Claudia". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + const patch = await parser.handle(input); + const quads = [ ...patch.deletes, ...patch.inserts, ...patch.conditions ]; + const uniqueGraphs = [ ...new Set(quads.map((entry): Term => entry.graph)) ]; + expect(uniqueGraphs).toHaveLength(1); + expect(uniqueGraphs[0]).toEqualRdfTerm(defaultGraph()); + }); + + it('errors if no solid:InsertDeletePatch is found.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { ?person ex:givenName "Alex". }; + solid:deletes { ?person ex:givenName "Claudia". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects.toThrow( + 'This patcher only supports N3 Patch documents with exactly 1 solid:InsertDeletePatch entry, but received 0.', + ); + }); + + it('errors if multiple solid:InsertDeletePatch entries are found.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:other a solid:InsertDeletePatch. + +_:rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { ?person ex:givenName "Alex". }; + solid:deletes { ?person ex:givenName "Claudia". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects.toThrow( + 'This patcher only supports N3 Patch documents with exactly 1 solid:InsertDeletePatch entry, but received 2.', + ); + }); + + it('errors if the patch subject is not a blank or named node.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +?rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { ?person ex:givenName "Alex". }; + solid:deletes { ?person ex:givenName "Claudia". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects + .toThrow('An N3 Patch subject needs to be a blank or named node.'); + }); + + it('errors if there are multiple where entries.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:where { ?person ex:givenName "Alex". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects + .toThrow('An N3 Patch can have at most 1 http://www.w3.org/ns/solid/terms#where.'); + }); + + it('errors if there are multiple delete entries.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:deletes { ex:person ex:familyName "Garcia". }; + solid:deletes { ex:person ex:givenName "Alex". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects + .toThrow('An N3 Patch can have at most 1 http://www.w3.org/ns/solid/terms#deletes.'); + }); + + it('errors if there are multiple insert entries.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:inserts { ex:person ex:familyName "Garcia". }; + solid:inserts { ex:person ex:givenName "Alex". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects + .toThrow('An N3 Patch can have at most 1 http://www.w3.org/ns/solid/terms#inserts.'); + }); + + it('errors if there are blank nodes in the delete formula.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { ?person ex:givenName "Alex". }; + solid:deletes { _:person ex:givenName "Claudia". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects + .toThrow('An N3 Patch delete/insert formula can not contain blank nodes.'); + }); + + it('errors if there are blank nodes in the insert formula.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { _:person ex:givenName "Alex". }; + solid:deletes { ?person ex:givenName "Claudia". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects + .toThrow('An N3 Patch delete/insert formula can not contain blank nodes.'); + }); + + it('errors if there are unknown variables in the delete formula.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { ?person ex:givenName "Alex". }; + solid:deletes { ?person ex:givenName ?name. }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects + .toThrow('An N3 Patch delete/insert formula can only contain variables found in the conditions formula.'); + }); + + it('errors if there are unknown variables in the insert formula.', async(): Promise => { + const n3 = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:where { ?person ex:familyName "Garcia"; ex:nickName "Garry". }; + solid:inserts { ?person ex:givenName ?name. }; + solid:deletes { ?person ex:givenName "Claudia". }.`; + input.request = guardedStreamFrom([ n3 ]) as HttpRequest; + await expect(parser.handle(input)).rejects + .toThrow('An N3 Patch delete/insert formula can only contain variables found in the conditions formula.'); + }); +}); diff --git a/test/unit/storage/patch/N3Patcher.test.ts b/test/unit/storage/patch/N3Patcher.test.ts new file mode 100644 index 000000000..f0a14c517 --- /dev/null +++ b/test/unit/storage/patch/N3Patcher.test.ts @@ -0,0 +1,155 @@ +import 'jest-rdf'; +import arrayifyStream from 'arrayify-stream'; +import { DataFactory } from 'n3'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { N3Patch } from '../../../../src/http/representation/N3Patch'; +import { N3Patcher } from '../../../../src/storage/patch/N3Patcher'; +import type { RepresentationPatcherInput } from '../../../../src/storage/patch/RepresentationPatcher'; +import { ConflictHttpError } from '../../../../src/util/errors/ConflictHttpError'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +const { namedNode, quad, variable } = DataFactory; + +describe('An N3Patcher', (): void => { + let patch: N3Patch; + let input: RepresentationPatcherInput; + const patcher = new N3Patcher(); + + beforeEach(async(): Promise => { + patch = new BasicRepresentation() as N3Patch; + patch.deletes = []; + patch.inserts = []; + patch.conditions = []; + + input = { + patch, + identifier: { path: 'http://example.com/foo' }, + }; + }); + + it('can only handle N3 Patches.', async(): Promise => { + await expect(patcher.canHandle(input)).resolves.toBeUndefined(); + input.patch = new BasicRepresentation() as N3Patch; + await expect(patcher.canHandle(input)).rejects.toThrow(NotImplementedHttpError); + }); + + it('returns an empty representation for an empty patch for new resources.', async(): Promise => { + patch.deletes = []; + patch.inserts = []; + patch.conditions = []; + const result = await patcher.handle(input); + expect(result.metadata.contentType).toBe('internal/quads'); + await expect(arrayifyStream(result.data)).resolves.toEqual([]); + }); + + it('returns the input representation for an empty patch.', async(): Promise => { + patch.deletes = []; + patch.inserts = []; + patch.conditions = []; + const representation = new BasicRepresentation([], 'internal/quads'); + input.representation = representation; + const result = await patcher.handle(input); + expect(result).toBe(representation); + }); + + it('errors if the input representation has the wrong content-type.', async(): Promise => { + // Just need a non-empty patch + patch.deletes = [ quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')) ]; + input.representation = new BasicRepresentation(); + await expect(patcher.handle(input)).rejects.toThrow('Quad stream was expected for patching.'); + }); + + it('can delete and insert triples.', async(): Promise => { + patch.deletes = [ quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')) ]; + patch.inserts = [ quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')) ]; + input.representation = new BasicRepresentation([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')), + ], 'internal/quads', false); + const result = await patcher.handle(input); + expect(result.metadata.contentType).toBe('internal/quads'); + await expect(arrayifyStream(result.data)).resolves.toBeRdfIsomorphic([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')), + ]); + }); + + it('can create new representations using insert.', async(): Promise => { + patch.inserts = [ quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')) ]; + const result = await patcher.handle(input); + expect(result.metadata.contentType).toBe('internal/quads'); + await expect(arrayifyStream(result.data)).resolves.toBeRdfIsomorphic([ + quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')), + ]); + }); + + it('can use conditions to target specific triples.', async(): Promise => { + patch.conditions = [ quad(variable('v'), namedNode('ex:p1'), namedNode('ex:o1')) ]; + patch.deletes = [ quad(variable('v'), namedNode('ex:p1'), namedNode('ex:o1')) ]; + patch.inserts = [ quad(variable('v'), namedNode('ex:p2'), namedNode('ex:o2')) ]; + input.representation = new BasicRepresentation([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')), + ], 'internal/quads', false); + const result = await patcher.handle(input); + expect(result.metadata.contentType).toBe('internal/quads'); + await expect(arrayifyStream(result.data)).resolves.toBeRdfIsomorphic([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + quad(namedNode('ex:s1'), namedNode('ex:p2'), namedNode('ex:o2')), + ]); + }); + + it('errors if the conditions find no match.', async(): Promise => { + patch.conditions = [ quad(variable('v'), namedNode('ex:p3'), namedNode('ex:o3')) ]; + input.representation = new BasicRepresentation([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')), + ], 'internal/quads', false); + const prom = patcher.handle(input); + await expect(prom).rejects.toThrow(ConflictHttpError); + await expect(prom).rejects.toThrow( + 'The document does not contain any matches for the N3 Patch solid:where condition.', + ); + }); + + it('errors if the conditions find multiple matches.', async(): Promise => { + patch.conditions = [ quad(variable('v'), namedNode('ex:p0'), namedNode('ex:o0')) ]; + input.representation = new BasicRepresentation([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + quad(namedNode('ex:s1'), namedNode('ex:p0'), namedNode('ex:o0')), + ], 'internal/quads', false); + const prom = patcher.handle(input); + await expect(prom).rejects.toThrow(ConflictHttpError); + await expect(prom).rejects.toThrow( + 'The document contains multiple matches for the N3 Patch solid:where condition, which is not allowed.', + ); + }); + + it('errors if the delete triples have no match.', async(): Promise => { + patch.deletes = [ quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')) ]; + input.representation = new BasicRepresentation([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + ], 'internal/quads', false); + const prom = patcher.handle(input); + await expect(prom).rejects.toThrow(ConflictHttpError); + await expect(prom).rejects.toThrow( + 'The document does not contain all triples the N3 Patch requests to delete, which is required for patching.', + ); + }); + + it('works correctly if there are duplicate delete triples.', async(): Promise => { + patch.conditions = [ quad(variable('v'), namedNode('ex:p1'), namedNode('ex:o1')) ]; + patch.deletes = [ + quad(variable('v'), namedNode('ex:p1'), namedNode('ex:o1')), + quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')), + ]; + input.representation = new BasicRepresentation([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')), + ], 'internal/quads', false); + const result = await patcher.handle(input); + expect(result.metadata.contentType).toBe('internal/quads'); + await expect(arrayifyStream(result.data)).resolves.toBeRdfIsomorphic([ + quad(namedNode('ex:s0'), namedNode('ex:p0'), namedNode('ex:o0')), + ]); + }); +}); diff --git a/test/unit/util/QuadUtil.test.ts b/test/unit/util/QuadUtil.test.ts index 236b65c34..c7ffc9d8a 100644 --- a/test/unit/util/QuadUtil.test.ts +++ b/test/unit/util/QuadUtil.test.ts @@ -1,6 +1,6 @@ import 'jest-rdf'; import { literal, namedNode, quad } from '@rdfjs/data-model'; -import { parseQuads, serializeQuads } from '../../../src/util/QuadUtil'; +import { parseQuads, serializeQuads, uniqueQuads } from '../../../src/util/QuadUtil'; import { guardedStreamFrom, readableToString } from '../../../src/util/StreamUtil'; describe('QuadUtil', (): void => { @@ -35,4 +35,18 @@ describe('QuadUtil', (): void => { ) ]); }); }); + + describe('#uniqueQuads', (): void => { + it('filters out duplicate quads.', async(): Promise => { + const quads = [ + quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')), + quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')), + quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')), + ]; + expect(uniqueQuads(quads)).toBeRdfIsomorphic([ + quad(namedNode('ex:s1'), namedNode('ex:p1'), namedNode('ex:o1')), + quad(namedNode('ex:s2'), namedNode('ex:p2'), namedNode('ex:o2')), + ]); + }); + }); }); diff --git a/test/unit/util/errors/HttpError.test.ts b/test/unit/util/errors/HttpError.test.ts index c62e6d3ef..7847bfea4 100644 --- a/test/unit/util/errors/HttpError.test.ts +++ b/test/unit/util/errors/HttpError.test.ts @@ -10,6 +10,7 @@ import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplemen import { PayloadHttpError } from '../../../../src/util/errors/PayloadHttpError'; import { PreconditionFailedHttpError } from '../../../../src/util/errors/PreconditionFailedHttpError'; import { UnauthorizedHttpError } from '../../../../src/util/errors/UnauthorizedHttpError'; +import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError'; import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError'; // Only used to make typings easier in the tests @@ -30,6 +31,7 @@ describe('HttpError', (): void => { [ 'PreconditionFailedHttpError', 412, PreconditionFailedHttpError ], [ 'PayloadHttpError', 413, PayloadHttpError ], [ 'UnsupportedMediaTypeHttpError', 415, UnsupportedMediaTypeHttpError ], + [ 'UnprocessableEntityHttpError', 422, UnprocessableEntityHttpError ], [ 'InternalServerError', 500, InternalServerError ], [ 'NotImplementedHttpError', 501, NotImplementedHttpError ], ]; diff --git a/test/unit/util/handlers/MethodFilterHandler.test.ts b/test/unit/util/handlers/MethodFilterHandler.test.ts index 0b1ef1a49..8f688144c 100644 --- a/test/unit/util/handlers/MethodFilterHandler.test.ts +++ b/test/unit/util/handlers/MethodFilterHandler.test.ts @@ -11,7 +11,7 @@ describe('A MethodFilterHandler', (): void => { const result = 'RESULT'; let operation: Operation; let source: jest.Mocked>; - let handler: MethodFilterHandler; + let handler: MethodFilterHandler; beforeEach(async(): Promise => { operation = { @@ -45,6 +45,17 @@ describe('A MethodFilterHandler', (): void => { expect(source.canHandle).toHaveBeenLastCalledWith(operation); }); + it('supports multiple object formats.', async(): Promise => { + let input: any = { method: 'PATCH' }; + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + input = { operation: { method: 'PATCH' }}; + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + input = { request: { method: 'PATCH' }}; + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + input = { unknown: { method: 'PATCH' }}; + await expect(handler.canHandle(input)).rejects.toThrow('Could not find method in input object.'); + }); + it('calls the source extractor.', async(): Promise => { await expect(handler.handle(operation)).resolves.toBe(result); expect(source.handle).toHaveBeenLastCalledWith(operation); diff --git a/test/util/FetchUtil.ts b/test/util/FetchUtil.ts index 31715c187..358470401 100644 --- a/test/util/FetchUtil.ts +++ b/test/util/FetchUtil.ts @@ -17,7 +17,7 @@ export async function getResource(url: string, expect(response.status).toBe(200); expect(response.headers.get('link')).toContain(`<${LDP.Resource}>; rel="type"`); expect(response.headers.get('link')).toContain(`<${url}.acl>; rel="acl"`); - expect(response.headers.get('accept-patch')).toBe('application/sparql-update'); + expect(response.headers.get('accept-patch')).toBe('application/sparql-update, text/n3'); expect(response.headers.get('ms-author-via')).toBe('SPARQL'); if (isContainer) { diff --git a/test/util/Util.ts b/test/util/Util.ts index 9b6ac63f8..25c132445 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -11,6 +11,7 @@ const portNames = [ 'LpdHandlerWithAuth', 'LpdHandlerWithoutAuth', 'Middleware', + 'N3Patch', 'PodCreation', 'RedisResourceLocker', 'RestrictedIdentity', From d067165b68a824143ff65f289d8a1e5e53d15103 Mon Sep 17 00:00:00 2001 From: Thomas Dupont Date: Wed, 9 Feb 2022 13:26:42 +0100 Subject: [PATCH 18/39] fix: Split AccountStorage and ForgotPasswordStorage (expiring now) --- .../handler/account-store/default.json | 14 +++++++++++ config/storage/key-value/memory.json | 5 ++++ config/storage/key-value/resource-store.json | 8 +++++++ .../storage/BaseAccountStore.ts | 24 ++++++++++++++----- .../storage/BaseAccountStore.test.ts | 10 +++++++- 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/config/identity/handler/account-store/default.json b/config/identity/handler/account-store/default.json index 896d9ebc4..71cad4ca3 100644 --- a/config/identity/handler/account-store/default.json +++ b/config/identity/handler/account-store/default.json @@ -8,7 +8,21 @@ "saltRounds": 10, "storage": { "@id": "urn:solid-server:default:AccountStorage" + }, + "forgotPasswordStorage": { + "@id": "urn:solid-server:default:ExpiringForgotPasswordStorage" } + }, + { + "comment": "Stores expiring data. This class has a `finalize` function that needs to be called after stopping the server.", + "@id": "urn:solid-server:default:ExpiringForgotPasswordStorage", + "@type": "WrappedExpiringStorage", + "source": { "@id": "urn:solid-server:default:ForgotPasswordStorage" } + }, + { + "comment": "Makes sure the expiring storage cleanup timer is stopped when the application needs to stop.", + "@id": "urn:solid-server:default:Finalizer", + "ParallelFinalizer:_finalizers": [ { "@id": "urn:solid-server:default:ExpiringForgotPasswordStorage" } ] } ] } diff --git a/config/storage/key-value/memory.json b/config/storage/key-value/memory.json index 41d766b38..19228f9af 100644 --- a/config/storage/key-value/memory.json +++ b/config/storage/key-value/memory.json @@ -33,6 +33,11 @@ "comment": "Storage used by setup components.", "@id": "urn:solid-server:default:SetupStorage", "@type": "MemoryMapStorage" + }, + { + "comment": "Storage used for ForgotPassword records", + "@id": "urn:solid-server:default:ForgotPasswordStorage", + "@type":"MemoryMapStorage" } ] } diff --git a/config/storage/key-value/resource-store.json b/config/storage/key-value/resource-store.json index 23d2d9aa3..74f90d67f 100644 --- a/config/storage/key-value/resource-store.json +++ b/config/storage/key-value/resource-store.json @@ -47,6 +47,14 @@ "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "container": "/.internal/accounts/" }, + { + "comment": "Storage used for ForgotPassword records", + "@id": "urn:solid-server:default:ForgotPasswordStorage", + "@type":"JsonResourceStorage", + "source": { "@id": "urn:solid-server:default:ResourceStore" }, + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "container": "/.internal/forgot-password/" + }, { "comment": "Storage used by setup components.", "@id": "urn:solid-server:default:SetupStorage", diff --git a/src/identity/interaction/email-password/storage/BaseAccountStore.ts b/src/identity/interaction/email-password/storage/BaseAccountStore.ts index 4cfc04d0b..2d990f86e 100644 --- a/src/identity/interaction/email-password/storage/BaseAccountStore.ts +++ b/src/identity/interaction/email-password/storage/BaseAccountStore.ts @@ -1,6 +1,7 @@ import assert from 'assert'; import { hash, compare } from 'bcrypt'; import { v4 } from 'uuid'; +import type { ExpiringStorage } from '../../../../storage/keyvalue/ExpiringStorage'; import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage'; import type { AccountSettings, AccountStore } from './AccountStore'; @@ -26,15 +27,25 @@ export interface ForgotPasswordPayload { export type EmailPasswordData = AccountPayload | ForgotPasswordPayload | AccountSettings; /** - * A EmailPasswordStore that uses a KeyValueStorage - * to persist its information. + * A EmailPasswordStore that uses a KeyValueStorage to persist its information and an + * ExpiringStorage to persist ForgotPassword records. + * + * `forgotPasswordExpiration` parameter is how long the ForgotPassword record should be + * stored in minutes. *(defaults to 15 minutes)* */ export class BaseAccountStore implements AccountStore { private readonly storage: KeyValueStorage; + private readonly forgotPasswordStorage: ExpiringStorage; private readonly saltRounds: number; + private readonly forgotPasswordExpiration: number; - public constructor(storage: KeyValueStorage, saltRounds: number) { + public constructor(storage: KeyValueStorage, + forgotPasswordStorage: ExpiringStorage, + saltRounds: number, + forgotPasswordExpiration = 15) { this.storage = storage; + this.forgotPasswordStorage = forgotPasswordStorage; + this.forgotPasswordExpiration = forgotPasswordExpiration * 60 * 1000; this.saltRounds = saltRounds; } @@ -130,20 +141,21 @@ export class BaseAccountStore implements AccountStore { public async generateForgotPasswordRecord(email: string): Promise { const recordId = v4(); await this.getAccountPayload(email, true); - await this.storage.set( + await this.forgotPasswordStorage.set( this.getForgotPasswordRecordResourceIdentifier(recordId), { recordId, email }, + this.forgotPasswordExpiration, ); return recordId; } public async getForgotPasswordRecord(recordId: string): Promise { const identifier = this.getForgotPasswordRecordResourceIdentifier(recordId); - const forgotPasswordRecord = await this.storage.get(identifier) as ForgotPasswordPayload | undefined; + const forgotPasswordRecord = await this.forgotPasswordStorage.get(identifier) as ForgotPasswordPayload | undefined; return forgotPasswordRecord?.email; } public async deleteForgotPasswordRecord(recordId: string): Promise { - await this.storage.delete(this.getForgotPasswordRecordResourceIdentifier(recordId)); + await this.forgotPasswordStorage.delete(this.getForgotPasswordRecordResourceIdentifier(recordId)); } } diff --git a/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts b/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts index e640b6b53..e4cb7c122 100644 --- a/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts +++ b/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts @@ -3,10 +3,12 @@ import type { EmailPasswordData, } from '../../../../../../src/identity/interaction/email-password/storage/BaseAccountStore'; import { BaseAccountStore } from '../../../../../../src/identity/interaction/email-password/storage/BaseAccountStore'; +import type { ExpiringStorage } from '../../../../../../src/storage/keyvalue/ExpiringStorage'; import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage'; describe('A BaseAccountStore', (): void => { let storage: KeyValueStorage; + let forgotPasswordStorage: ExpiringStorage; const saltRounds = 11; let store: BaseAccountStore; const email = 'test@test.com'; @@ -22,7 +24,13 @@ describe('A BaseAccountStore', (): void => { delete: jest.fn((id: string): any => map.delete(id)), } as any; - store = new BaseAccountStore(storage, saltRounds); + forgotPasswordStorage = { + get: jest.fn((id: string): any => map.get(id)), + set: jest.fn((id: string, value: any): any => map.set(id, value)), + delete: jest.fn((id: string): any => map.delete(id)), + } as any; + + store = new BaseAccountStore(storage, forgotPasswordStorage, saltRounds); }); it('can create accounts.', async(): Promise => { From c216efd62fcc05aa1db5a0046c3dbc512e7f2d62 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Fri, 11 Feb 2022 10:00:12 +0100 Subject: [PATCH 19/39] feat: Allow for custom CLI and variable options * feat: (AppRunner) Mechanism to configure cli args and derive componentsjs vars from them implemented * fix: (AppRunner) tidying * fix: (AppRunner) tidying up * fix: (AppRunner) runCli method made sync * fix; (VarResolver) refactored to multiple files, and other stylistic fixes. * chore: (AppRunner) Uses builder pattern for yargs base arguments setup to enable better typescript inference * fix(AppRunner): refactoring AppRunner and VarResolver * fix(AppRunner): refactoring AppRunner promise handling * fix(AppRunner): verror dependency removal * fix: Simplify CLI error handling * feat: Use same config for both CLI and app instantiation * fix: Update typings and imports * feat: Split VariableResolver behaviour to 2 classes * feat: Move default value behaviour from CLI to ValueComputers * test: Add unit tests for new CLI classes * feat: Integrate new CLI configuration with all default configurations * feat: Add createApp function to AppRunner * docs: Update comments in CLI-related classes * fix: Various fixes and refactors Co-authored-by: damooo --- RELEASE_NOTES.md | 11 +- bin/server.js | 3 +- config/app/README.md | 8 + config/app/variables/cli/cli.json | 67 +++ config/app/variables/default.json | 16 + config/app/variables/resolver/resolver.json | 65 +++ config/default.json | 1 + config/dynamic.json | 1 + config/example-https-file.json | 1 + config/file-no-setup.json | 1 + config/file.json | 1 + config/memory-subdomains.json | 1 + config/path-routing.json | 1 + config/restrict-idp.json | 1 + config/sparql-endpoint-no-setup.json | 1 + config/sparql-endpoint.json | 1 + config/util/variables/default.json | 2 +- package-lock.json | 14 +- package.json | 2 +- src/index.ts | 15 + src/init/AppRunner.ts | 275 +++++----- src/init/CliResolver.ts | 16 + src/init/cli/CliExtractor.ts | 18 + src/init/cli/YargsCliExtractor.ts | 61 +++ .../variables/CombinedSettingsResolver.ts | 27 + src/init/variables/SettingsResolver.ts | 9 + src/init/variables/Types.ts | 16 + .../extractors/AssetPathExtractor.ts | 26 + .../variables/extractors/BaseUrlExtractor.ts | 24 + src/init/variables/extractors/KeyExtractor.ts | 21 + .../variables/extractors/SettingsExtractor.ts | 7 + src/logging/LogLevel.ts | 4 +- test/unit/init/AppRunner.test.ts | 479 +++++++++--------- test/unit/init/CliResolver.test.ts | 13 + test/unit/init/cli/YargsCliExtractor.test.ts | 83 +++ .../CombinedSettingsResolver.test.ts | 38 ++ .../extractors/AssetPathExtractor.test.ts | 28 + .../extractors/BaseUrlExtractor.test.ts | 22 + .../variables/extractors/KeyExtractor.test.ts | 19 + 39 files changed, 1026 insertions(+), 373 deletions(-) create mode 100644 config/app/variables/cli/cli.json create mode 100644 config/app/variables/default.json create mode 100644 config/app/variables/resolver/resolver.json create mode 100644 src/init/CliResolver.ts create mode 100644 src/init/cli/CliExtractor.ts create mode 100644 src/init/cli/YargsCliExtractor.ts create mode 100644 src/init/variables/CombinedSettingsResolver.ts create mode 100644 src/init/variables/SettingsResolver.ts create mode 100644 src/init/variables/Types.ts create mode 100644 src/init/variables/extractors/AssetPathExtractor.ts create mode 100644 src/init/variables/extractors/BaseUrlExtractor.ts create mode 100644 src/init/variables/extractors/KeyExtractor.ts create mode 100644 src/init/variables/extractors/SettingsExtractor.ts create mode 100644 test/unit/init/CliResolver.test.ts create mode 100644 test/unit/init/cli/YargsCliExtractor.test.ts create mode 100644 test/unit/init/variables/CombinedSettingsResolver.test.ts create mode 100644 test/unit/init/variables/extractors/AssetPathExtractor.test.ts create mode 100644 test/unit/init/variables/extractors/BaseUrlExtractor.test.ts create mode 100644 test/unit/init/variables/extractors/KeyExtractor.test.ts diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7e91caf8c..9a75fc02d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,16 +3,23 @@ ## v3.0.0 ### New features - The Identity Provider now uses the `webid` scope as required for Solid-OIDC. -- The `VoidLocker` can be used to disable locking for development/testing purposes. This can be enabled by changing the `/config/util/resource-locker/` import to `debug-void.json` +- The `VoidLocker` can be used to disable locking for development/testing purposes. + This can be enabled by changing the `/config/util/resource-locker/` import to `debug-void.json` - Added support for setting a quota on the server. See the `config/quota-file.json` config for an example. - An official docker image is now built on each version tag and published at https://hub.docker.com/r/solidproject/community-server. - Added support for N3 Patch. +- It is now possible to customize arguments to the `community-solid-server` command, + which enables passing custom variables to configurations and setting new default values. +- The AppRunner functions have changed to require Components.js variables. + This is important for anyone who starts the server from code. ### Configuration changes You might need to make changes to your v2 configuration if you use a custom config. The following changes pertain to the imports in the default configs: -- ... +- A new configuration option needs to be imported: + - `/app/variables/default/json` contains everything related to parsing CLI arguments + and assigning values to variables. The following changes are relevant for v2 custom configs that replaced certain features. - Conversion has been simplified so most converters are part of the conversion chain: diff --git a/bin/server.js b/bin/server.js index 32a79b07b..0eda47fe6 100755 --- a/bin/server.js +++ b/bin/server.js @@ -1,4 +1,3 @@ #!/usr/bin/env node -// eslint-disable-next-line @typescript-eslint/naming-convention const { AppRunner } = require('..'); -new AppRunner().runCli(process); +new AppRunner().runCliSync(process); diff --git a/config/app/README.md b/config/app/README.md index 1e7b2965a..5366a38a1 100644 --- a/config/app/README.md +++ b/config/app/README.md @@ -20,3 +20,11 @@ Handles the setup page the first time the server is started. * *optional*: Setup is available at `/setup` but the server can already be used. Everyone can access the setup page so make sure to complete that as soon as possible. * *required*: All requests will be redirected to the setup page until setup is completed. + +## Variables +Handles parsing CLI parameters and assigning values to Components.js variables. +Some parts of the configuration contains variables that can be set as arguments on the command-line. +That way, you don't have to edit the configuration files for small changes, +such as starting the server with a different hostname. +Here, you can customize the mapping from CLI arguments into values for those variables. +* *default*: Assigns CLI parameters for all variables defined in `/config/util/variables/default.json` diff --git a/config/app/variables/cli/cli.json b/config/app/variables/cli/cli.json new file mode 100644 index 000000000..cc44651c2 --- /dev/null +++ b/config/app/variables/cli/cli.json @@ -0,0 +1,67 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Extracts CLI arguments into a key/value object. Config and mainModulePath are only defined here so their description is returned.", + "@id": "urn:solid-server-app-setup:default:CliExtractor", + "@type": "YargsCliExtractor", + "parameters": { + "config": { + "alias": "c", + "requiresArg": true, + "type": "string", + "describe": "The configuration for the server. The default only stores data in memory; to persist to your filesystem, use @css:config/file.json." + }, + "mainModulePath": { + "alias": "m", + "requiresArg": true, + "type": "string", + "describe": "Path from where Components.js will start its lookup when initializing configurations." + }, + "loggingLevel": { + "alias": "l", + "requiresArg": true, + "type": "string", + "describe": "The detail level of logging; useful for debugging problems." + }, + "baseUrl": { + "alias": "b", + "requiresArg": true, + "type": "string", + "describe": "The public URL of your server." + }, + "port": { + "alias": "p", + "requiresArg": true, + "type": "number", + "describe": "The TCP port on which the server runs." + }, + "rootFilePath": { + "alias": "f", + "requiresArg": true, + "type": "string", + "describe": "Root folder of the server, when using a file-based configuration." + }, + "showStackTrace": { + "alias": "t", + "type": "boolean", + "describe": "Enables detailed logging on error pages." + }, + "sparqlEndpoint": { + "alias": "s", + "requiresArg": true, + "type": "string", + "describe": "URL of the SPARQL endpoint, when using a quadstore-based configuration." + }, + "podConfigJson": { + "requiresArg": true, + "type": "string", + "describe": "Path to the file that keeps track of dynamic Pod configurations." + } + }, + "options": { + "usage": "node ./bin/server.js [args]" + } + } + ] +} diff --git a/config/app/variables/default.json b/config/app/variables/default.json new file mode 100644 index 000000000..103467e2a --- /dev/null +++ b/config/app/variables/default.json @@ -0,0 +1,16 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "import": [ + "files-scs:config/app/variables/cli/cli.json", + "files-scs:config/app/variables/resolver/resolver.json" + ], + "@graph": [ + { + "comment": "Combines a CliExtractor and SettingsResolver to be used by the AppRunner.", + "@id": "urn:solid-server-app-setup:default:CliResolver", + "@type": "CliResolver", + "cliExtractor": { "@id": "urn:solid-server-app-setup:default:CliExtractor" }, + "settingsResolver": { "@id": "urn:solid-server-app-setup:default:SettingsResolver" } + } + ] +} diff --git a/config/app/variables/resolver/resolver.json b/config/app/variables/resolver/resolver.json new file mode 100644 index 000000000..46cf92016 --- /dev/null +++ b/config/app/variables/resolver/resolver.json @@ -0,0 +1,65 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Converts an input key/value object into an object mapping values to Components.js variables", + "@id": "urn:solid-server-app-setup:default:SettingsResolver", + "@type": "CombinedSettingsResolver", + "computers": [ + { + "CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:baseUrl", + "CombinedSettingsResolver:_computers_value": { + "@type": "BaseUrlExtractor" + } + }, + { + "CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:loggingLevel", + "CombinedSettingsResolver:_computers_value": { + "@type": "KeyExtractor", + "key": "loggingLevel", + "defaultValue": "info" + } + }, + { + "CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:port", + "CombinedSettingsResolver:_computers_value": { + "@type": "KeyExtractor", + "key": "port", + "defaultValue": 3000 + } + }, + { + "CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:rootFilePath", + "CombinedSettingsResolver:_computers_value": { + "@type": "AssetPathExtractor", + "key": "rootFilePath", + "defaultPath": "./" + } + }, + { + "CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:sparqlEndpoint", + "CombinedSettingsResolver:_computers_value": { + "@type": "KeyExtractor", + "key": "sparqlEndpoint" + } + }, + { + "CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:showStackTrace", + "CombinedSettingsResolver:_computers_value": { + "@type": "KeyExtractor", + "key": "showStackTrace", + "defaultValue": false + } + }, + { + "CombinedSettingsResolver:_computers_key": "urn:solid-server:default:variable:AssetPathResolver", + "CombinedSettingsResolver:_computers_value": { + "@type": "AssetPathExtractor", + "key": "podConfigJson", + "defaultPath": "./pod-config.json" + } + } + ] + } + ] +} diff --git a/config/default.json b/config/default.json index aa4346295..709856448 100644 --- a/config/default.json +++ b/config/default.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-prefilled-root.json", "files-scs:config/app/setup/optional.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/dynamic.json b/config/dynamic.json index d6552ca80..d731158e9 100644 --- a/config/dynamic.json +++ b/config/dynamic.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", "files-scs:config/app/setup/required.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/example-https-file.json b/config/example-https-file.json index 77b2163a3..8ec5248bc 100644 --- a/config/example-https-file.json +++ b/config/example-https-file.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", "files-scs:config/app/setup/required.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", diff --git a/config/file-no-setup.json b/config/file-no-setup.json index 26ff5f0ba..b4c5096a2 100644 --- a/config/file-no-setup.json +++ b/config/file-no-setup.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", "files-scs:config/app/setup/disabled.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/file.json b/config/file.json index 4e220273a..40948c7ed 100644 --- a/config/file.json +++ b/config/file.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", "files-scs:config/app/setup/required.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/memory-subdomains.json b/config/memory-subdomains.json index fef343dd2..bb1d61362 100644 --- a/config/memory-subdomains.json +++ b/config/memory-subdomains.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", "files-scs:config/app/setup/optional.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/path-routing.json b/config/path-routing.json index f05e98833..50296f710 100644 --- a/config/path-routing.json +++ b/config/path-routing.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", "files-scs:config/app/setup/disabled.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/restrict-idp.json b/config/restrict-idp.json index 4ad5edfd2..f531031d9 100644 --- a/config/restrict-idp.json +++ b/config/restrict-idp.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", "files-scs:config/app/setup/disabled.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/sparql-endpoint-no-setup.json b/config/sparql-endpoint-no-setup.json index 4f4f9731d..04ea8ebbe 100644 --- a/config/sparql-endpoint-no-setup.json +++ b/config/sparql-endpoint-no-setup.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", "files-scs:config/app/setup/disabled.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/sparql-endpoint.json b/config/sparql-endpoint.json index af64b6186..bf63d0453 100644 --- a/config/sparql-endpoint.json +++ b/config/sparql-endpoint.json @@ -4,6 +4,7 @@ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", "files-scs:config/app/setup/required.json", + "files-scs:config/app/variables/default.json", "files-scs:config/http/handler/default.json", "files-scs:config/http/middleware/websockets.json", "files-scs:config/http/server-factory/websockets.json", diff --git a/config/util/variables/default.json b/config/util/variables/default.json index d259bcfb6..beb5aabb3 100644 --- a/config/util/variables/default.json +++ b/config/util/variables/default.json @@ -8,7 +8,7 @@ "@type": "Variable" }, { - "comment": "Needs to be set to the base URL of the server for authnetication and authorization to function.", + "comment": "Needs to be set to the base URL of the server for authentication and authorization to function.", "@id": "urn:solid-server:default:variable:baseUrl", "@type": "Variable" }, diff --git a/package-lock.json b/package-lock.json index cb0b422a4..fb0f745d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "@types/url-join": "^4.0.0", "@types/uuid": "^8.3.0", "@types/ws": "^8.2.0", - "@types/yargs": "^17.0.0", + "@types/yargs": "^17.0.8", "arrayify-stream": "^1.0.0", "async-lock": "^1.3.0", "bcrypt": "^5.0.1", @@ -4982,9 +4982,9 @@ } }, "node_modules/@types/yargs": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.0.tgz", - "integrity": "sha512-RS7u2X7vdXjVQs160PWY1pjLBw6GJj04utojn0KU8p2rRZR37FSzzK6XOT+KLzT/DVbDYRyezroc0LHIvM5Z2A==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.8.tgz", + "integrity": "sha512-wDeUwiUmem9FzsyysEwRukaEdDNcwbROvQ9QGRKaLI6t+IltNzbn4/i4asmB10auvZGQCzSQ6t0GSczEThlUXw==", "dependencies": { "@types/yargs-parser": "*" } @@ -19663,9 +19663,9 @@ } }, "@types/yargs": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.0.tgz", - "integrity": "sha512-RS7u2X7vdXjVQs160PWY1pjLBw6GJj04utojn0KU8p2rRZR37FSzzK6XOT+KLzT/DVbDYRyezroc0LHIvM5Z2A==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.8.tgz", + "integrity": "sha512-wDeUwiUmem9FzsyysEwRukaEdDNcwbROvQ9QGRKaLI6t+IltNzbn4/i4asmB10auvZGQCzSQ6t0GSczEThlUXw==", "requires": { "@types/yargs-parser": "*" } diff --git a/package.json b/package.json index 1baf8d757..9bf645625 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "@types/url-join": "^4.0.0", "@types/uuid": "^8.3.0", "@types/ws": "^8.2.0", - "@types/yargs": "^17.0.0", + "@types/yargs": "^17.0.8", "arrayify-stream": "^1.0.0", "async-lock": "^1.3.0", "bcrypt": "^5.0.1", diff --git a/src/index.ts b/src/index.ts index 498bd2212..f3ed16de2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -180,9 +180,24 @@ export * from './init/final/ParallelFinalizer'; // Init/Setup export * from './init/setup/SetupHttpHandler'; +// Init/Cli +export * from './init/cli/CliExtractor'; +export * from './init/cli/YargsCliExtractor'; + +// Init/Variables/Extractors +export * from './init/variables/extractors/KeyExtractor'; +export * from './init/variables/extractors/AssetPathExtractor'; +export * from './init/variables/extractors/BaseUrlExtractor'; +export * from './init/variables/extractors/SettingsExtractor'; + +// Init/Variables +export * from './init/variables/CombinedSettingsResolver'; +export * from './init/variables/SettingsResolver'; + // Init export * from './init/App'; export * from './init/AppRunner'; +export * from './init/CliResolver'; export * from './init/ConfigPodInitializer'; export * from './init/ContainerInitializer'; export * from './init/Initializer'; diff --git a/src/init/AppRunner.ts b/src/init/AppRunner.ts index 87bda754e..39b4913a7 100644 --- a/src/init/AppRunner.ts +++ b/src/init/AppRunner.ts @@ -1,152 +1,199 @@ /* eslint-disable unicorn/no-process-exit */ - -import type { ReadStream, WriteStream } from 'tty'; -import type { IComponentsManagerBuilderOptions, LogLevel } from 'componentsjs'; +import type { WriteStream } from 'tty'; +import type { IComponentsManagerBuilderOptions } from 'componentsjs'; import { ComponentsManager } from 'componentsjs'; import yargs from 'yargs'; +import { LOG_LEVELS } from '../logging/LogLevel'; import { getLoggerFor } from '../logging/LogUtil'; -import { ensureTrailingSlash, resolveAssetPath, modulePathPlaceholder } from '../util/PathUtil'; +import { createErrorMessage, isError } from '../util/errors/ErrorUtil'; +import { modulePathPlaceholder, resolveAssetPath } from '../util/PathUtil'; import type { App } from './App'; +import type { CliResolver } from './CliResolver'; +import type { CliArgv, VariableBindings } from './variables/Types'; -const defaultConfig = `${modulePathPlaceholder}config/default.json`; +const DEFAULT_CONFIG = `${modulePathPlaceholder}config/default.json`; -export interface CliParams { - loggingLevel: string; - port: number; - baseUrl?: string; - rootFilePath?: string; - sparqlEndpoint?: string; - showStackTrace?: boolean; - podConfigJson?: string; -} +const DEFAULT_CLI_RESOLVER = 'urn:solid-server-app-setup:default:CliResolver'; +const DEFAULT_APP = 'urn:solid-server:default:App'; +const CORE_CLI_PARAMETERS = { + config: { type: 'string', alias: 'c', default: DEFAULT_CONFIG, requiresArg: true }, + loggingLevel: { type: 'string', alias: 'l', default: 'info', requiresArg: true, choices: LOG_LEVELS }, + mainModulePath: { type: 'string', alias: 'm', requiresArg: true }, +} as const; + +/** + * A class that can be used to instantiate and start a server based on a Component.js configuration. + */ export class AppRunner { private readonly logger = getLoggerFor(this); /** * Starts the server with a given config. * This method can be used to start the server from within another JavaScript application. + * Keys of the `variableBindings` object should be Components.js variables. + * E.g.: `{ 'urn:solid-server:default:variable:rootFilePath': '.data' }`. + * * @param loaderProperties - Components.js loader properties. * @param configFile - Path to the server config file. - * @param variableParams - Variables to pass into the config file. + * @param variableBindings - Parameters to pass into the VariableResolver. */ public async run( loaderProperties: IComponentsManagerBuilderOptions, configFile: string, - variableParams: CliParams, + variableBindings: VariableBindings, ): Promise { - const app = await this.createApp(loaderProperties, configFile, variableParams); + const app = await this.create(loaderProperties, configFile, variableBindings); await app.start(); } /** - * Starts the server as a command-line application. - * Made non-async to lower the risk of unhandled promise rejections. - * @param args - Command line arguments. - * @param stderr - Standard error stream. - */ - public runCli({ - argv = process.argv, - stderr = process.stderr, - }: { - argv?: string[]; - stdin?: ReadStream; - stdout?: WriteStream; - stderr?: WriteStream; - } = {}): void { - // Parse the command-line arguments - // eslint-disable-next-line no-sync - const params = yargs(argv.slice(2)) - .strict() - .usage('node ./bin/server.js [args]') - .check((args): boolean => { - if (args._.length > 0) { - throw new Error(`Unsupported positional arguments: "${args._.join('", "')}"`); - } - for (const key of Object.keys(args)) { - // We have no options that allow for arrays - const val = args[key]; - if (key !== '_' && Array.isArray(val)) { - throw new Error(`Multiple values were provided for: "${key}": "${val.join('", "')}"`); - } - } - return true; - }) - .options({ - baseUrl: { type: 'string', alias: 'b', requiresArg: true }, - config: { type: 'string', alias: 'c', default: defaultConfig, requiresArg: true }, - loggingLevel: { type: 'string', alias: 'l', default: 'info', requiresArg: true }, - mainModulePath: { type: 'string', alias: 'm', requiresArg: true }, - port: { type: 'number', alias: 'p', default: 3000, requiresArg: true }, - rootFilePath: { type: 'string', alias: 'f', default: './', requiresArg: true }, - showStackTrace: { type: 'boolean', alias: 't', default: false }, - sparqlEndpoint: { type: 'string', alias: 's', requiresArg: true }, - podConfigJson: { type: 'string', default: './pod-config.json', requiresArg: true }, - }) - .parseSync(); - - // Gather settings for instantiating the server - const loaderProperties: IComponentsManagerBuilderOptions = { - mainModulePath: resolveAssetPath(params.mainModulePath), - dumpErrorState: true, - logLevel: params.loggingLevel as LogLevel, - }; - const configFile = resolveAssetPath(params.config); - - // Create and execute the app - this.createApp(loaderProperties, configFile, params) - .then( - async(app): Promise => app.start(), - (error: Error): void => { - // Instantiation of components has failed, so there is no logger to use - stderr.write(`Error: could not instantiate server from ${configFile}\n`); - stderr.write(`${error.stack}\n`); - process.exit(1); - }, - ).catch((error): void => { - this.logger.error(`Could not start server: ${error}`, { error }); - process.exit(1); - }); - } - - /** - * Creates the main app object to start the server from a given config. + * Returns an App object, created with the given config, that can start and stop the Solid server. + * Keys of the `variableBindings` object should be Components.js variables. + * E.g.: `{ 'urn:solid-server:default:variable:rootFilePath': '.data' }`. + * * @param loaderProperties - Components.js loader properties. - * @param configFile - Path to a Components.js config file. - * @param variables - Variables to pass into the config file. + * @param configFile - Path to the server config file. + * @param variableBindings - Bindings of Components.js variables. */ - public async createApp( + public async create( loaderProperties: IComponentsManagerBuilderOptions, configFile: string, - variables: CliParams | Record, + variableBindings: VariableBindings, ): Promise { - // Translate command-line parameters if needed - if (typeof variables.loggingLevel === 'string') { - variables = this.createVariables(variables as CliParams); - } + // Create a resolver to translate (non-core) CLI parameters into values for variables + const componentsManager = await this.createComponentsManager(loaderProperties, configFile); - // Set up Components.js - const componentsManager = await ComponentsManager.build(loaderProperties); - await componentsManager.configRegistry.register(configFile); - - // Create the app - const app = 'urn:solid-server:default:App'; - return await componentsManager.instantiate(app, { variables }); + // Create the application using the translated variable values + return componentsManager.instantiate(DEFAULT_APP, { variables: variableBindings }); } /** - * Translates command-line parameters into Components.js variables. + * Starts the server as a command-line application. + * Will exit the process on failure. + * + * Made non-async to lower the risk of unhandled promise rejections. + * This is only relevant when this is used to start as a Node.js application on its own, + * if you use this as part of your code you probably want to use the async version. + * + * @param argv - Command line arguments. + * @param stderr - Stream that should be used to output errors before the logger is enabled. */ - protected createVariables(params: CliParams): Record { - return { - 'urn:solid-server:default:variable:baseUrl': - params.baseUrl ? ensureTrailingSlash(params.baseUrl) : `http://localhost:${params.port}/`, - 'urn:solid-server:default:variable:loggingLevel': params.loggingLevel, - 'urn:solid-server:default:variable:port': params.port, - 'urn:solid-server:default:variable:rootFilePath': resolveAssetPath(params.rootFilePath), - 'urn:solid-server:default:variable:sparqlEndpoint': params.sparqlEndpoint, - 'urn:solid-server:default:variable:showStackTrace': params.showStackTrace, - 'urn:solid-server:default:variable:podConfigJson': resolveAssetPath(params.podConfigJson), + public runCliSync({ argv, stderr = process.stderr }: { argv?: CliArgv; stderr?: WriteStream }): void { + this.runCli(argv).catch((error): never => { + stderr.write(createErrorMessage(error)); + process.exit(1); + }); + } + + /** + * Starts the server as a command-line application. + * @param argv - Command line arguments. + */ + public async runCli(argv?: CliArgv): Promise { + const app = await this.createCli(argv); + try { + await app.start(); + } catch (error: unknown) { + this.logger.error(`Could not start the server: ${createErrorMessage(error)}`); + this.resolveError('Could not start the server', error); + } + } + + /** + * Returns an App object, created by parsing the Command line arguments, that can start and stop the Solid server. + * Will exit the process on failure. + * + * @param argv - Command line arguments. + */ + public async createCli(argv: CliArgv = process.argv): Promise { + // Parse only the core CLI arguments needed to load the configuration + const yargv = yargs(argv.slice(2)) + .usage('node ./bin/server.js [args]') + .options(CORE_CLI_PARAMETERS) + // We disable help here as it would only show the core parameters + .help(false); + + const params = await yargv.parse(); + + const loaderProperties = { + mainModulePath: resolveAssetPath(params.mainModulePath), + dumpErrorState: true, + logLevel: params.loggingLevel, }; + + const config = resolveAssetPath(params.config); + + // Create the Components.js manager used to build components from the provided config + let componentsManager: ComponentsManager; + try { + componentsManager = await this.createComponentsManager(loaderProperties, config); + } catch (error: unknown) { + // Print help of the expected core CLI parameters + const help = await yargv.getHelp(); + this.resolveError(`${help}\n\nCould not build the config files from ${config}`, error); + } + + // Build the CLI components and use them to generate values for the Components.js variables + const variables = await this.resolveVariables(componentsManager, argv); + + // Build and start the actual server application using the generated variable values + return await this.createApp(componentsManager, variables); + } + + /** + * Creates the Components Manager that will be used for instantiating. + */ + public async createComponentsManager( + loaderProperties: IComponentsManagerBuilderOptions, + configFile: string, + ): Promise> { + const componentsManager = await ComponentsManager.build(loaderProperties); + await componentsManager.configRegistry.register(configFile); + return componentsManager; + } + + /** + * Handles the first Components.js instantiation, + * where CLI settings and variable mappings are created. + */ + private async resolveVariables(componentsManager: ComponentsManager, argv: string[]): + Promise { + try { + // Create a CliResolver, which combines a CliExtractor and a VariableResolver + const resolver = await componentsManager.instantiate(DEFAULT_CLI_RESOLVER, {}); + // Convert CLI args to CLI bindings + const cliValues = await resolver.cliExtractor.handleSafe(argv); + // Convert CLI bindings into variable bindings + return await resolver.settingsResolver.handleSafe(cliValues); + } catch (error: unknown) { + this.resolveError(`Could not load the config variables`, error); + } + } + + /** + * The second Components.js instantiation, + * where the App is created and started using the variable mappings. + */ + private async createApp(componentsManager: ComponentsManager, variables: Record): Promise { + try { + // Create the app + return await componentsManager.instantiate(DEFAULT_APP, { variables }); + } catch (error: unknown) { + this.resolveError(`Could not create the server`, error); + } + } + + /** + * Throws a new error that provides additional information through the extra message. + * Also appends the stack trace to the message. + * This is needed for errors that are thrown before the logger is created as we can't log those the standard way. + */ + private resolveError(message: string, error: unknown): never { + let errorMessage = `${message}\nCause: ${createErrorMessage(error)}\n`; + if (isError(error)) { + errorMessage += `${error.stack}\n`; + } + throw new Error(errorMessage); } } diff --git a/src/init/CliResolver.ts b/src/init/CliResolver.ts new file mode 100644 index 000000000..3ecf1880e --- /dev/null +++ b/src/init/CliResolver.ts @@ -0,0 +1,16 @@ +import type { CliExtractor } from './cli/CliExtractor'; +import type { SettingsResolver } from './variables/SettingsResolver'; + +/** + * A class that combines a {@link CliExtractor} and a {@link SettingsResolver}. + * Mainly exists so both such classes can be generated in a single Components.js instance. + */ +export class CliResolver { + public readonly cliExtractor: CliExtractor; + public readonly settingsResolver: SettingsResolver; + + public constructor(cliExtractor: CliExtractor, settingsResolver: SettingsResolver) { + this.cliExtractor = cliExtractor; + this.settingsResolver = settingsResolver; + } +} diff --git a/src/init/cli/CliExtractor.ts b/src/init/cli/CliExtractor.ts new file mode 100644 index 000000000..b11a6b96d --- /dev/null +++ b/src/init/cli/CliExtractor.ts @@ -0,0 +1,18 @@ +import { AsyncHandler } from '../../util/handlers/AsyncHandler'; +import type { CliArgv, Settings } from '../variables/Types'; + +/** + * Converts the input CLI arguments into an easily parseable key/value object. + * + * Due to how the application is built, there are certain CLI parameters + * that need to be parsed before this class can be instantiated. + * These can be ignored by this class as they will have been handled before it is called, + * but that does mean that this class should not error if they are present, + * e.g., by being strict throwing an error on these unexpected parameters. + * + * The following core CLI parameters are mandatory: + * - -c / \--config + * - -m / \--mainModulePath + * - -l / \--loggingLevel + */ +export abstract class CliExtractor extends AsyncHandler {} diff --git a/src/init/cli/YargsCliExtractor.ts b/src/init/cli/YargsCliExtractor.ts new file mode 100644 index 000000000..2330174e2 --- /dev/null +++ b/src/init/cli/YargsCliExtractor.ts @@ -0,0 +1,61 @@ +/* eslint-disable tsdoc/syntax */ +import type { Arguments, Argv, Options } from 'yargs'; +import yargs from 'yargs'; +import { CliExtractor } from './CliExtractor'; + +export type YargsArgOptions = Record; + +export interface CliOptions { + // Usage string printed in case of CLI errors + usage?: string; + // Errors on unknown CLI parameters when enabled. + // @see https://yargs.js.org/docs/#api-reference-strictenabledtrue + strictMode?: boolean; + // Loads CLI args from environment variables when enabled. + // @see http://yargs.js.org/docs/#api-reference-envprefix + loadFromEnv?: boolean; + // Prefix to be used when `loadFromEnv` is enabled. + // @see http://yargs.js.org/docs/#api-reference-envprefix + envVarPrefix?: string; +} + +/** + * Parses CLI args using the yargs library. + * Specific settings can be enabled through the provided options. + */ +export class YargsCliExtractor extends CliExtractor { + protected readonly yargsArgOptions: YargsArgOptions; + protected readonly yargvOptions: CliOptions; + + /** + * @param parameters - Parameters that should be parsed from the CLI. @range {json} + * Format details can be found at https://yargs.js.org/docs/#api-reference-optionskey-opt + * @param options - Additional options to configure yargs. @range {json} + */ + public constructor(parameters: YargsArgOptions = {}, options: CliOptions = {}) { + super(); + this.yargsArgOptions = parameters; + this.yargvOptions = options; + } + + public async handle(argv: readonly string[]): Promise { + return this.createYArgv(argv).parse(); + } + + /** + * Creates the yargs Argv object based on the input CLI argv. + */ + private createYArgv(argv: readonly string[]): Argv { + let yArgv = yargs(argv.slice(2)); + if (this.yargvOptions.usage !== undefined) { + yArgv = yArgv.usage(this.yargvOptions.usage); + } + if (this.yargvOptions.strictMode) { + yArgv = yArgv.strict(); + } + if (this.yargvOptions.loadFromEnv) { + yArgv = yArgv.env(this.yargvOptions.envVarPrefix ?? ''); + } + return yArgv.options(this.yargsArgOptions); + } +} diff --git a/src/init/variables/CombinedSettingsResolver.ts b/src/init/variables/CombinedSettingsResolver.ts new file mode 100644 index 000000000..9fe2a9df7 --- /dev/null +++ b/src/init/variables/CombinedSettingsResolver.ts @@ -0,0 +1,27 @@ +import { createErrorMessage } from '../../util/errors/ErrorUtil'; +import type { SettingsExtractor } from './extractors/SettingsExtractor'; +import { SettingsResolver } from './SettingsResolver'; + +/** + * Generates variable values by running a set of {@link SettingsExtractor}s on the input. + */ +export class CombinedSettingsResolver extends SettingsResolver { + public readonly computers: Record; + + public constructor(computers: Record) { + super(); + this.computers = computers; + } + + public async handle(input: Record): Promise> { + const vars: Record = {}; + for (const [ name, computer ] of Object.entries(this.computers)) { + try { + vars[name] = await computer.handleSafe(input); + } catch (err: unknown) { + throw new Error(`Error in computing value for variable ${name}: ${createErrorMessage(err)}`); + } + } + return vars; + } +} diff --git a/src/init/variables/SettingsResolver.ts b/src/init/variables/SettingsResolver.ts new file mode 100644 index 000000000..c2f2fa985 --- /dev/null +++ b/src/init/variables/SettingsResolver.ts @@ -0,0 +1,9 @@ +import { AsyncHandler } from '../../util/handlers/AsyncHandler'; +import type { Settings, VariableBindings } from './Types'; + +/** + * Converts a key/value object, extracted from the CLI or passed as a parameter, + * into a new key/value object where the keys are variables defined in the Components.js configuration. + * The resulting values are the values that should be assigned to those variables. + */ +export abstract class SettingsResolver extends AsyncHandler {} diff --git a/src/init/variables/Types.ts b/src/init/variables/Types.ts new file mode 100644 index 000000000..4cb155d27 --- /dev/null +++ b/src/init/variables/Types.ts @@ -0,0 +1,16 @@ +// These types are used to clarify what is expected for the CLI-related handlers + +/** + * A list of command line arguments provided to the process. + */ +export type CliArgv = string[]; + +/** + * A key/value mapping of parsed command line arguments. + */ +export type Settings = Record; + +/** + * A key/value mapping of Components.js variables. + */ +export type VariableBindings = Record; diff --git a/src/init/variables/extractors/AssetPathExtractor.ts b/src/init/variables/extractors/AssetPathExtractor.ts new file mode 100644 index 000000000..7c14e5760 --- /dev/null +++ b/src/init/variables/extractors/AssetPathExtractor.ts @@ -0,0 +1,26 @@ +import { resolveAssetPath } from '../../../util/PathUtil'; +import type { Settings } from '../Types'; +import { SettingsExtractor } from './SettingsExtractor'; + +/** + * A {@link SettingsExtractor} that converts a path value to an absolute asset path by making use of `resolveAssetPath`. + * Returns the default path in case it is defined and no path was found in the map. + */ +export class AssetPathExtractor extends SettingsExtractor { + private readonly key: string; + private readonly defaultPath?: string; + + public constructor(key: string, defaultPath?: string) { + super(); + this.key = key; + this.defaultPath = defaultPath; + } + + public async handle(args: Settings): Promise { + const path = args[this.key] ?? this.defaultPath; + if (typeof path !== 'string') { + throw new Error(`Invalid ${this.key} argument`); + } + return resolveAssetPath(path); + } +} diff --git a/src/init/variables/extractors/BaseUrlExtractor.ts b/src/init/variables/extractors/BaseUrlExtractor.ts new file mode 100644 index 000000000..3081e1c79 --- /dev/null +++ b/src/init/variables/extractors/BaseUrlExtractor.ts @@ -0,0 +1,24 @@ +import { ensureTrailingSlash } from '../../../util/PathUtil'; +import type { Settings } from '../Types'; +import { SettingsExtractor } from './SettingsExtractor'; + +/** + * A {@link SettingsExtractor} that that generates the base URL based on the input `baseUrl` value, + * or by using the port if the first isn't provided. + */ +export class BaseUrlExtractor extends SettingsExtractor { + private readonly defaultPort: number; + + public constructor(defaultPort = 3000) { + super(); + this.defaultPort = defaultPort; + } + + public async handle(args: Settings): Promise { + if (typeof args.baseUrl === 'string') { + return ensureTrailingSlash(args.baseUrl); + } + const port = args.port ?? this.defaultPort; + return `http://localhost:${port}/`; + } +} diff --git a/src/init/variables/extractors/KeyExtractor.ts b/src/init/variables/extractors/KeyExtractor.ts new file mode 100644 index 000000000..e97129db8 --- /dev/null +++ b/src/init/variables/extractors/KeyExtractor.ts @@ -0,0 +1,21 @@ +import type { Settings } from '../Types'; +import { SettingsExtractor } from './SettingsExtractor'; + +/** + * A simple {@link SettingsExtractor} that extracts a single value from the input map. + * Returns the default value if it was defined in case no value was found in the map. + */ +export class KeyExtractor extends SettingsExtractor { + private readonly key: string; + private readonly defaultValue: unknown; + + public constructor(key: string, defaultValue?: unknown) { + super(); + this.key = key; + this.defaultValue = defaultValue; + } + + public async handle(args: Settings): Promise { + return typeof args[this.key] === 'undefined' ? this.defaultValue : args[this.key]; + } +} diff --git a/src/init/variables/extractors/SettingsExtractor.ts b/src/init/variables/extractors/SettingsExtractor.ts new file mode 100644 index 000000000..c1a7a3c95 --- /dev/null +++ b/src/init/variables/extractors/SettingsExtractor.ts @@ -0,0 +1,7 @@ +import { AsyncHandler } from '../../../util/handlers/AsyncHandler'; +import type { Settings } from '../Types'; + +/** + * A handler that computes a specific value from a given map of values. + */ +export abstract class SettingsExtractor extends AsyncHandler {} diff --git a/src/logging/LogLevel.ts b/src/logging/LogLevel.ts index e67f9755e..136944833 100644 --- a/src/logging/LogLevel.ts +++ b/src/logging/LogLevel.ts @@ -1,4 +1,6 @@ +export const LOG_LEVELS = [ 'error', 'warn', 'info', 'verbose', 'debug', 'silly' ] as const; + /** * Different log levels, from most important to least important. */ -export type LogLevel = 'error' | 'warn' | 'info' | 'verbose' | 'debug' | 'silly'; +export type LogLevel = typeof LOG_LEVELS[number]; diff --git a/test/unit/init/AppRunner.test.ts b/test/unit/init/AppRunner.test.ts index ec206e4df..4ed64b7be 100644 --- a/test/unit/init/AppRunner.test.ts +++ b/test/unit/init/AppRunner.test.ts @@ -1,14 +1,38 @@ import { ComponentsManager } from 'componentsjs'; import type { App } from '../../../src/init/App'; import { AppRunner } from '../../../src/init/AppRunner'; +import type { CliExtractor } from '../../../src/init/cli/CliExtractor'; +import type { SettingsResolver } from '../../../src/init/variables/SettingsResolver'; import { joinFilePath } from '../../../src/util/PathUtil'; const app: jest.Mocked = { start: jest.fn(), } as any; +const defaultParameters = { + port: 3000, + logLevel: 'info', +}; +const cliExtractor: jest.Mocked = { + handleSafe: jest.fn().mockResolvedValue(defaultParameters), +} as any; + +const defaultVariables = { + 'urn:solid-server:default:variable:port': 3000, + 'urn:solid-server:default:variable:loggingLevel': 'info', +}; +const settingsResolver: jest.Mocked = { + handleSafe: jest.fn().mockResolvedValue(defaultVariables), +} as any; + const manager: jest.Mocked> = { - instantiate: jest.fn(async(): Promise => app), + instantiate: jest.fn(async(iri: string): Promise => { + switch (iri) { + case 'urn:solid-server-app-setup:default:CliResolver': return { cliExtractor, settingsResolver }; + case 'urn:solid-server:default:App': return app; + default: throw new Error('unknown iri'); + } + }), configRegistry: { register: jest.fn(), }, @@ -22,7 +46,6 @@ jest.mock('componentsjs', (): any => ({ })); jest.spyOn(process, 'cwd').mockReturnValue('/var/cwd'); -const error = jest.spyOn(console, 'error').mockImplementation(jest.fn()); const write = jest.spyOn(process.stderr, 'write').mockImplementation(jest.fn()); const exit = jest.spyOn(process, 'exit').mockImplementation(jest.fn() as any); @@ -31,8 +54,52 @@ describe('AppRunner', (): void => { jest.clearAllMocks(); }); + describe('create', (): void => { + it('creates an App with the provided settings.', async(): Promise => { + const variables = { + 'urn:solid-server:default:variable:port': 3000, + 'urn:solid-server:default:variable:loggingLevel': 'info', + 'urn:solid-server:default:variable:rootFilePath': '/var/cwd/', + 'urn:solid-server:default:variable:showStackTrace': false, + 'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json', + }; + const createdApp = await new AppRunner().create( + { + mainModulePath: joinFilePath(__dirname, '../../../'), + dumpErrorState: true, + logLevel: 'info', + }, + joinFilePath(__dirname, '../../../config/default.json'), + variables, + ); + expect(createdApp).toBe(app); + + expect(ComponentsManager.build).toHaveBeenCalledTimes(1); + expect(ComponentsManager.build).toHaveBeenCalledWith({ + dumpErrorState: true, + logLevel: 'info', + mainModulePath: joinFilePath(__dirname, '../../../'), + }); + expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); + expect(manager.configRegistry.register) + .toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json')); + expect(manager.instantiate).toHaveBeenCalledTimes(1); + expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server:default:App', { variables }); + expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(0); + expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(0); + expect(app.start).toHaveBeenCalledTimes(0); + }); + }); + describe('run', (): void => { - it('starts the server with default settings.', async(): Promise => { + it('starts the server with provided settings.', async(): Promise => { + const variables = { + 'urn:solid-server:default:variable:port': 3000, + 'urn:solid-server:default:variable:loggingLevel': 'info', + 'urn:solid-server:default:variable:rootFilePath': '/var/cwd/', + 'urn:solid-server:default:variable:showStackTrace': false, + 'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json', + }; await new AppRunner().run( { mainModulePath: joinFilePath(__dirname, '../../../'), @@ -40,13 +107,7 @@ describe('AppRunner', (): void => { logLevel: 'info', }, joinFilePath(__dirname, '../../../config/default.json'), - { - port: 3000, - loggingLevel: 'info', - rootFilePath: '/var/cwd/', - showStackTrace: false, - podConfigJson: '/var/cwd/pod-config.json', - }, + variables, ); expect(ComponentsManager.build).toHaveBeenCalledTimes(1); @@ -59,35 +120,17 @@ describe('AppRunner', (): void => { expect(manager.configRegistry.register) .toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json')); expect(manager.instantiate).toHaveBeenCalledTimes(1); - expect(manager.instantiate).toHaveBeenCalledWith( - 'urn:solid-server:default:App', - { - variables: { - 'urn:solid-server:default:variable:port': 3000, - 'urn:solid-server:default:variable:baseUrl': 'http://localhost:3000/', - 'urn:solid-server:default:variable:rootFilePath': '/var/cwd/', - 'urn:solid-server:default:variable:sparqlEndpoint': undefined, - 'urn:solid-server:default:variable:loggingLevel': 'info', - 'urn:solid-server:default:variable:showStackTrace': false, - 'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json', - }, - }, - ); + expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server:default:App', { variables }); + expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(0); + expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(0); expect(app.start).toHaveBeenCalledTimes(1); expect(app.start).toHaveBeenCalledWith(); }); }); - describe('runCli', (): void => { - it('starts the server with default settings.', async(): Promise => { - new AppRunner().runCli({ - argv: [ 'node', 'script' ], - }); - - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); + describe('createCli', (): void => { + it('creates the server with default settings.', async(): Promise => { + await expect(new AppRunner().createCli([ 'node', 'script' ])).resolves.toBe(app); expect(ComponentsManager.build).toHaveBeenCalledTimes(1); expect(ComponentsManager.build).toHaveBeenCalledWith({ @@ -98,133 +141,21 @@ describe('AppRunner', (): void => { expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); expect(manager.configRegistry.register) .toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json')); - expect(manager.instantiate).toHaveBeenCalledTimes(1); - expect(manager.instantiate).toHaveBeenCalledWith( + expect(manager.instantiate).toHaveBeenCalledTimes(2); + expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server-app-setup:default:CliResolver', {}); + expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(1); + expect(cliExtractor.handleSafe).toHaveBeenCalledWith([ 'node', 'script' ]); + expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(1); + expect(settingsResolver.handleSafe).toHaveBeenCalledWith(defaultParameters); + expect(manager.instantiate).toHaveBeenNthCalledWith(2, 'urn:solid-server:default:App', - { - variables: { - 'urn:solid-server:default:variable:port': 3000, - 'urn:solid-server:default:variable:baseUrl': 'http://localhost:3000/', - 'urn:solid-server:default:variable:rootFilePath': '/var/cwd/', - 'urn:solid-server:default:variable:sparqlEndpoint': undefined, - 'urn:solid-server:default:variable:loggingLevel': 'info', - 'urn:solid-server:default:variable:showStackTrace': false, - 'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json', - }, - }, - ); - expect(app.start).toHaveBeenCalledTimes(1); - expect(app.start).toHaveBeenCalledWith(); - }); - - it('accepts abbreviated flags.', async(): Promise => { - new AppRunner().runCli({ - argv: [ - 'node', 'script', - '-b', 'http://pod.example/', - '-c', 'myconfig.json', - '-f', '/root', - '-l', 'debug', - '-m', 'module/path', - '-p', '4000', - '-s', 'http://localhost:5000/sparql', - '-t', - '--podConfigJson', '/different-path.json', - ], - }); - - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); - - expect(ComponentsManager.build).toHaveBeenCalledTimes(1); - expect(ComponentsManager.build).toHaveBeenCalledWith({ - dumpErrorState: true, - logLevel: 'debug', - mainModulePath: '/var/cwd/module/path', - }); - expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); - expect(manager.configRegistry.register).toHaveBeenCalledWith('/var/cwd/myconfig.json'); - expect(manager.instantiate).toHaveBeenCalledWith( - 'urn:solid-server:default:App', - { - variables: { - 'urn:solid-server:default:variable:baseUrl': 'http://pod.example/', - 'urn:solid-server:default:variable:loggingLevel': 'debug', - 'urn:solid-server:default:variable:port': 4000, - 'urn:solid-server:default:variable:rootFilePath': '/root', - 'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql', - 'urn:solid-server:default:variable:showStackTrace': true, - 'urn:solid-server:default:variable:podConfigJson': '/different-path.json', - }, - }, - ); - }); - - it('accepts full flags.', async(): Promise => { - new AppRunner().runCli({ - argv: [ - 'node', 'script', - '--baseUrl', 'http://pod.example/', - '--config', 'myconfig.json', - '--loggingLevel', 'debug', - '--mainModulePath', 'module/path', - '--port', '4000', - '--rootFilePath', 'root', - '--sparqlEndpoint', 'http://localhost:5000/sparql', - '--showStackTrace', - '--podConfigJson', '/different-path.json', - ], - }); - - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); - - expect(ComponentsManager.build).toHaveBeenCalledTimes(1); - expect(ComponentsManager.build).toHaveBeenCalledWith({ - dumpErrorState: true, - logLevel: 'debug', - mainModulePath: '/var/cwd/module/path', - }); - expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); - expect(manager.configRegistry.register).toHaveBeenCalledWith('/var/cwd/myconfig.json'); - expect(manager.instantiate).toHaveBeenCalledWith( - 'urn:solid-server:default:App', - { - variables: { - 'urn:solid-server:default:variable:baseUrl': 'http://pod.example/', - 'urn:solid-server:default:variable:loggingLevel': 'debug', - 'urn:solid-server:default:variable:port': 4000, - 'urn:solid-server:default:variable:rootFilePath': '/var/cwd/root', - 'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql', - 'urn:solid-server:default:variable:showStackTrace': true, - 'urn:solid-server:default:variable:podConfigJson': '/different-path.json', - }, - }, - ); - }); - - it('accepts asset paths for the config flag.', async(): Promise => { - new AppRunner().runCli({ - argv: [ - 'node', 'script', - '--config', '@css:config/file.json', - ], - }); - await new Promise(setImmediate); - - expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); - expect(manager.configRegistry.register).toHaveBeenCalledWith( - joinFilePath(__dirname, '../../../config/file.json'), - ); + { variables: defaultVariables }); + expect(app.start).toHaveBeenCalledTimes(0); }); it('uses the default process.argv in case none are provided.', async(): Promise => { const { argv } = process; - process.argv = [ + const argvParameters = [ 'node', 'script', '-b', 'http://pod.example/', '-c', 'myconfig.json', @@ -236,13 +167,9 @@ describe('AppRunner', (): void => { '-t', '--podConfigJson', '/different-path.json', ]; + process.argv = argvParameters; - new AppRunner().runCli(); - - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); + await expect(new AppRunner().createCli()).resolves.toBe(app); expect(ComponentsManager.build).toHaveBeenCalledTimes(1); expect(ComponentsManager.build).toHaveBeenCalledWith({ @@ -252,119 +179,181 @@ describe('AppRunner', (): void => { }); expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); expect(manager.configRegistry.register).toHaveBeenCalledWith('/var/cwd/myconfig.json'); - expect(manager.instantiate).toHaveBeenCalledWith( + expect(manager.instantiate).toHaveBeenCalledTimes(2); + expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server-app-setup:default:CliResolver', {}); + expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(1); + expect(cliExtractor.handleSafe).toHaveBeenCalledWith(argvParameters); + expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(1); + expect(settingsResolver.handleSafe).toHaveBeenCalledWith(defaultParameters); + expect(manager.instantiate).toHaveBeenNthCalledWith(2, 'urn:solid-server:default:App', - { - variables: { - 'urn:solid-server:default:variable:baseUrl': 'http://pod.example/', - 'urn:solid-server:default:variable:loggingLevel': 'debug', - 'urn:solid-server:default:variable:port': 4000, - 'urn:solid-server:default:variable:rootFilePath': '/root', - 'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql', - 'urn:solid-server:default:variable:showStackTrace': true, - 'urn:solid-server:default:variable:podConfigJson': '/different-path.json', - }, - }, - ); + { variables: defaultVariables }); + expect(app.start).toHaveBeenCalledTimes(0); process.argv = argv; }); - it('exits with output to stderr when instantiation fails.', async(): Promise => { - manager.instantiate.mockRejectedValueOnce(new Error('Fatal')); - new AppRunner().runCli({ - argv: [ 'node', 'script' ], - }); + it('throws an error if creating a ComponentsManager fails.', async(): Promise => { + (manager.configRegistry.register as jest.Mock).mockRejectedValueOnce(new Error('Fatal')); - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); + let caughtError: Error = new Error('should disappear'); + try { + await new AppRunner().createCli([ 'node', 'script' ]); + } catch (error: unknown) { + caughtError = error as Error; + } + expect(caughtError.message).toMatch(/^Could not build the config files from .*default\.json/mu); + expect(caughtError.message).toMatch(/^Cause: Fatal/mu); - expect(write).toHaveBeenCalledTimes(2); - expect(write).toHaveBeenNthCalledWith(1, - expect.stringMatching(/^Error: could not instantiate server from .*default\.json/u)); - expect(write).toHaveBeenNthCalledWith(2, - expect.stringMatching(/^Error: Fatal/u)); - - expect(exit).toHaveBeenCalledTimes(1); - expect(exit).toHaveBeenCalledWith(1); + expect(write).toHaveBeenCalledTimes(0); + expect(exit).toHaveBeenCalledTimes(0); }); - it('exits without output to stderr when initialization fails.', async(): Promise => { - app.start.mockRejectedValueOnce(new Error('Fatal')); - new AppRunner().runCli({ - argv: [ 'node', 'script' ], - }); + it('throws an error if instantiating the CliResolver fails.', async(): Promise => { + manager.instantiate.mockRejectedValueOnce(new Error('Fatal')); - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); + let caughtError: Error = new Error('should disappear'); + try { + await new AppRunner().createCli([ 'node', 'script' ]); + } catch (error: unknown) { + caughtError = error as Error; + } + expect(caughtError.message).toMatch(/^Could not load the config variables/mu); + expect(caughtError.message).toMatch(/^Cause: Fatal/mu); + + expect(write).toHaveBeenCalledTimes(0); + expect(exit).toHaveBeenCalledTimes(0); + }); + + it('throws an error if instantiating the server fails.', async(): Promise => { + // We want the second call to fail + manager.instantiate + .mockResolvedValueOnce({ cliExtractor, settingsResolver }) + .mockRejectedValueOnce(new Error('Fatal')); + + let caughtError: Error = new Error('should disappear'); + try { + await new AppRunner().createCli([ 'node', 'script' ]); + } catch (error: unknown) { + caughtError = error as Error; + } + expect(caughtError.message).toMatch(/^Could not create the server/mu); + expect(caughtError.message).toMatch(/^Cause: Fatal/mu); + + expect(write).toHaveBeenCalledTimes(0); + expect(exit).toHaveBeenCalledTimes(0); + }); + + it('throws an error if non-error objects get thrown.', async(): Promise => { + (manager.configRegistry.register as jest.Mock).mockRejectedValueOnce('NotAnError'); + + let caughtError: Error = new Error('should disappear'); + try { + await new AppRunner().createCli([ 'node', 'script' ]); + } catch (error: unknown) { + caughtError = error as Error; + } + expect(caughtError.message).toMatch(/^Cause: Unknown error: NotAnError$/mu); + + expect(write).toHaveBeenCalledTimes(0); + expect(exit).toHaveBeenCalledTimes(0); + }); + }); + + describe('runCli', (): void => { + it('runs the server.', async(): Promise => { + await expect(new AppRunner().runCli([ 'node', 'script' ])).resolves.toBeUndefined(); + + expect(ComponentsManager.build).toHaveBeenCalledTimes(1); + expect(ComponentsManager.build).toHaveBeenCalledWith({ + dumpErrorState: true, + logLevel: 'info', + mainModulePath: joinFilePath(__dirname, '../../../'), }); + expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); + expect(manager.configRegistry.register) + .toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json')); + expect(manager.instantiate).toHaveBeenCalledTimes(2); + expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server-app-setup:default:CliResolver', {}); + expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(1); + expect(cliExtractor.handleSafe).toHaveBeenCalledWith([ 'node', 'script' ]); + expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(1); + expect(settingsResolver.handleSafe).toHaveBeenCalledWith(defaultParameters); + expect(manager.instantiate).toHaveBeenNthCalledWith(2, + 'urn:solid-server:default:App', + { variables: defaultVariables }); + expect(app.start).toHaveBeenCalledTimes(1); + expect(app.start).toHaveBeenLastCalledWith(); + }); + + it('throws an error if the server could not start.', async(): Promise => { + app.start.mockRejectedValueOnce(new Error('Fatal')); + + let caughtError: Error = new Error('should disappear'); + try { + await new AppRunner().runCli([ 'node', 'script' ]); + } catch (error: unknown) { + caughtError = error as Error; + } + expect(caughtError.message).toMatch(/^Could not start the server/mu); + expect(caughtError.message).toMatch(/^Cause: Fatal/mu); + + expect(app.start).toHaveBeenCalledTimes(1); expect(write).toHaveBeenCalledTimes(0); - expect(exit).toHaveBeenCalledWith(1); + expect(exit).toHaveBeenCalledTimes(0); }); + }); - it('exits when unknown options are passed to the main executable.', async(): Promise => { - new AppRunner().runCli({ - argv: [ 'node', 'script', '--foo' ], - }); + describe('runCliSync', (): void => { + it('starts the server.', async(): Promise => { + // eslint-disable-next-line no-sync + new AppRunner().runCliSync({ argv: [ 'node', 'script' ]}); // Wait until app.start has been called, because we can't await AppRunner.run. await new Promise((resolve): void => { setImmediate(resolve); }); - expect(error).toHaveBeenCalledWith('Unknown argument: foo'); - expect(exit).toHaveBeenCalledTimes(1); - expect(exit).toHaveBeenCalledWith(1); + expect(ComponentsManager.build).toHaveBeenCalledTimes(1); + expect(ComponentsManager.build).toHaveBeenCalledWith({ + dumpErrorState: true, + logLevel: 'info', + mainModulePath: joinFilePath(__dirname, '../../../'), + }); + expect(manager.configRegistry.register).toHaveBeenCalledTimes(1); + expect(manager.configRegistry.register) + .toHaveBeenCalledWith(joinFilePath(__dirname, '/../../../config/default.json')); + expect(manager.instantiate).toHaveBeenCalledTimes(2); + expect(manager.instantiate).toHaveBeenNthCalledWith(1, 'urn:solid-server-app-setup:default:CliResolver', {}); + expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(1); + expect(cliExtractor.handleSafe).toHaveBeenCalledWith([ 'node', 'script' ]); + expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(1); + expect(settingsResolver.handleSafe).toHaveBeenCalledWith(defaultParameters); + expect(manager.instantiate).toHaveBeenNthCalledWith(2, + 'urn:solid-server:default:App', + { variables: defaultVariables }); + expect(app.start).toHaveBeenCalledTimes(1); + expect(app.start).toHaveBeenLastCalledWith(); }); - it('exits when no value is passed to the main executable for an argument.', async(): Promise => { - new AppRunner().runCli({ - argv: [ 'node', 'script', '-s' ], - }); + it('exits the process and writes to stderr if there was an error.', async(): Promise => { + manager.instantiate.mockRejectedValueOnce(new Error('Fatal')); - // Wait until app.start has been called, because we can't await AppRunner.run. + // eslint-disable-next-line no-sync + new AppRunner().runCliSync({ argv: [ 'node', 'script' ]}); + + // Wait until app.start has been called, because we can't await AppRunner.runCli. await new Promise((resolve): void => { setImmediate(resolve); }); - expect(error).toHaveBeenCalledWith('Not enough arguments following: s'); + expect(write).toHaveBeenCalledTimes(1); + expect(write).toHaveBeenLastCalledWith(expect.stringMatching(/Cause: Fatal/mu)); + expect(exit).toHaveBeenCalledTimes(1); - expect(exit).toHaveBeenCalledWith(1); - }); - - it('exits when unknown parameters are passed to the main executable.', async(): Promise => { - new AppRunner().runCli({ - argv: [ 'node', 'script', 'foo', 'bar', 'foo.txt', 'bar.txt' ], - }); - - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); - - expect(error).toHaveBeenCalledWith('Unsupported positional arguments: "foo", "bar", "foo.txt", "bar.txt"'); - expect(exit).toHaveBeenCalledTimes(1); - expect(exit).toHaveBeenCalledWith(1); - }); - - it('exits when multiple values for a parameter are passed.', async(): Promise => { - new AppRunner().runCli({ - argv: [ 'node', 'script', '-l', 'info', '-l', 'debug' ], - }); - - // Wait until app.start has been called, because we can't await AppRunner.run. - await new Promise((resolve): void => { - setImmediate(resolve); - }); - - expect(error).toHaveBeenCalledWith('Multiple values were provided for: "l": "info", "debug"'); - expect(exit).toHaveBeenCalledTimes(1); - expect(exit).toHaveBeenCalledWith(1); + expect(exit).toHaveBeenLastCalledWith(1); }); }); }); diff --git a/test/unit/init/CliResolver.test.ts b/test/unit/init/CliResolver.test.ts new file mode 100644 index 000000000..65a45f036 --- /dev/null +++ b/test/unit/init/CliResolver.test.ts @@ -0,0 +1,13 @@ +import type { CliExtractor } from '../../../src/init/cli/CliExtractor'; +import { CliResolver } from '../../../src/init/CliResolver'; +import type { SettingsResolver } from '../../../src/init/variables/SettingsResolver'; + +describe('A CliResolver', (): void => { + it('stores a CliExtractor and SettingsResolver.', async(): Promise => { + const cliExtractor: CliExtractor = { canHandle: jest.fn().mockResolvedValue('CLI!') } as any; + const settingsResolver: SettingsResolver = { canHandle: jest.fn().mockResolvedValue('Settings!') } as any; + const cliResolver = new CliResolver(cliExtractor, settingsResolver); + expect(cliResolver.cliExtractor).toBe(cliExtractor); + expect(cliResolver.settingsResolver).toBe(settingsResolver); + }); +}); diff --git a/test/unit/init/cli/YargsCliExtractor.test.ts b/test/unit/init/cli/YargsCliExtractor.test.ts new file mode 100644 index 000000000..312aa0c82 --- /dev/null +++ b/test/unit/init/cli/YargsCliExtractor.test.ts @@ -0,0 +1,83 @@ +import type { YargsArgOptions } from '../../../../src/init/cli/YargsCliExtractor'; +import { YargsCliExtractor } from '../../../../src/init/cli/YargsCliExtractor'; + +const error = jest.spyOn(console, 'error').mockImplementation(jest.fn()); +const log = jest.spyOn(console, 'log').mockImplementation(jest.fn()); +const exit = jest.spyOn(process, 'exit').mockImplementation(jest.fn() as any); +describe('A YargsCliExtractor', (): void => { + const parameters: YargsArgOptions = { + baseUrl: { alias: 'b', requiresArg: true, type: 'string' }, + port: { alias: 'p', requiresArg: true, type: 'number' }, + }; + let extractor: YargsCliExtractor; + + beforeEach(async(): Promise => { + extractor = new YargsCliExtractor(parameters); + }); + + afterEach(async(): Promise => { + jest.clearAllMocks(); + }); + + it('returns parsed results.', async(): Promise => { + const argv = [ 'node', 'script', '-b', 'http://localhost:3000/', '-p', '3000' ]; + await expect(extractor.handle(argv)).resolves.toEqual(expect.objectContaining({ + baseUrl: 'http://localhost:3000/', + port: 3000, + })); + }); + + it('accepts full flags.', async(): Promise => { + const argv = [ 'node', 'script', '--baseUrl', 'http://localhost:3000/', '--port', '3000' ]; + await expect(extractor.handle(argv)).resolves.toEqual(expect.objectContaining({ + baseUrl: 'http://localhost:3000/', + port: 3000, + })); + }); + + it('defaults to no parameters if none are provided.', async(): Promise => { + extractor = new YargsCliExtractor(); + const argv = [ 'node', 'script', '-b', 'http://localhost:3000/', '-p', '3000' ]; + await expect(extractor.handle(argv)).resolves.toEqual(expect.objectContaining({})); + }); + + it('prints usage if defined.', async(): Promise => { + extractor = new YargsCliExtractor(parameters, { usage: 'node ./bin/server.js [args]' }); + const argv = [ 'node', 'script', '--help' ]; + await extractor.handle(argv); + expect(exit).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenLastCalledWith(expect.stringMatching(/^node \.\/bin\/server\.js \[args\]/u)); + }); + + it('can error on undefined parameters.', async(): Promise => { + extractor = new YargsCliExtractor(parameters, { strictMode: true }); + const argv = [ 'node', 'script', '--unsupported' ]; + await extractor.handle(argv); + expect(exit).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith('Unknown argument: unsupported'); + }); + + it('can parse environment variables.', async(): Promise => { + // While the code below does go into the corresponding values, + // yargs does not see the new environment variable for some reason. + // It does see all the env variables that were already in there + // (which can be tested by setting envVarPrefix to ''). + // This can probably be fixed by changing jest setup to already load the custom env before loading the tests, + // but does not seem worth it just for this test. + const { env } = process; + // eslint-disable-next-line @typescript-eslint/naming-convention + process.env = { ...env, TEST_ENV_PORT: '3333' }; + extractor = new YargsCliExtractor(parameters, { loadFromEnv: true, envVarPrefix: 'TEST_ENV' }); + const argv = [ 'node', 'script', '-b', 'http://localhost:3333/' ]; + await expect(extractor.handle(argv)).resolves.toEqual(expect.objectContaining({ + baseUrl: 'http://localhost:3333/', + })); + process.env = env; + + // This part is here for the case of envVarPrefix being defined + // since it doesn't make much sense to test it if the above doesn't work + extractor = new YargsCliExtractor(parameters, { loadFromEnv: true }); + await extractor.handle(argv); + }); +}); diff --git a/test/unit/init/variables/CombinedSettingsResolver.test.ts b/test/unit/init/variables/CombinedSettingsResolver.test.ts new file mode 100644 index 000000000..8f262e14a --- /dev/null +++ b/test/unit/init/variables/CombinedSettingsResolver.test.ts @@ -0,0 +1,38 @@ +import { CombinedSettingsResolver } from '../../../../src/init/variables/CombinedSettingsResolver'; +import type { SettingsExtractor } from '../../../../src/init/variables/extractors/SettingsExtractor'; + +describe('A CombinedSettingsResolver', (): void => { + const values = { test: 'data' }; + const varPort = 'urn:solid-server:default:variable:port'; + const varLog = 'urn:solid-server:default:variable:loggingLevel'; + let computerPort: jest.Mocked; + let computerLog: jest.Mocked; + let resolver: CombinedSettingsResolver; + + beforeEach(async(): Promise => { + computerPort = { + handleSafe: jest.fn().mockResolvedValue(3000), + } as any; + + computerLog = { + handleSafe: jest.fn().mockResolvedValue('info'), + } as any; + + resolver = new CombinedSettingsResolver({ + [varPort]: computerPort, + [varLog]: computerLog, + }); + }); + + it('assigns variable values based on the Computers output.', async(): Promise => { + await expect(resolver.handle(values)).resolves.toEqual({ + [varPort]: 3000, + [varLog]: 'info', + }); + }); + + it('rethrows the error if something goes wrong.', async(): Promise => { + computerPort.handleSafe.mockRejectedValueOnce(new Error('bad data')); + await expect(resolver.handle(values)).rejects.toThrow(`Error in computing value for variable ${varPort}: bad data`); + }); +}); diff --git a/test/unit/init/variables/extractors/AssetPathExtractor.test.ts b/test/unit/init/variables/extractors/AssetPathExtractor.test.ts new file mode 100644 index 000000000..352d5d5d8 --- /dev/null +++ b/test/unit/init/variables/extractors/AssetPathExtractor.test.ts @@ -0,0 +1,28 @@ +import { AssetPathExtractor } from '../../../../../src/init/variables/extractors/AssetPathExtractor'; +import { joinFilePath } from '../../../../../src/util/PathUtil'; + +describe('An AssetPathExtractor', (): void => { + let resolver: AssetPathExtractor; + + beforeEach(async(): Promise => { + resolver = new AssetPathExtractor('path'); + }); + + it('resolves the asset path.', async(): Promise => { + await expect(resolver.handle({ path: '/var/data' })).resolves.toBe('/var/data'); + }); + + it('errors if the path is not a string.', async(): Promise => { + await expect(resolver.handle({ path: 1234 })).rejects.toThrow('Invalid path argument'); + }); + + it('converts paths containing the module path placeholder.', async(): Promise => { + await expect(resolver.handle({ path: '@css:config/file.json' })) + .resolves.toEqual(joinFilePath(__dirname, '../../../../../config/file.json')); + }); + + it('defaults to the given path if none is provided.', async(): Promise => { + resolver = new AssetPathExtractor('path', '/root'); + await expect(resolver.handle({ otherPath: '/var/data' })).resolves.toBe('/root'); + }); +}); diff --git a/test/unit/init/variables/extractors/BaseUrlExtractor.test.ts b/test/unit/init/variables/extractors/BaseUrlExtractor.test.ts new file mode 100644 index 000000000..21b46e36a --- /dev/null +++ b/test/unit/init/variables/extractors/BaseUrlExtractor.test.ts @@ -0,0 +1,22 @@ +import { BaseUrlExtractor } from '../../../../../src/init/variables/extractors/BaseUrlExtractor'; + +describe('A BaseUrlExtractor', (): void => { + let computer: BaseUrlExtractor; + + beforeEach(async(): Promise => { + computer = new BaseUrlExtractor(); + }); + + it('extracts the baseUrl parameter.', async(): Promise => { + await expect(computer.handle({ baseUrl: 'http://example.com/', port: 3333 })) + .resolves.toBe('http://example.com/'); + }); + + it('uses the port parameter if baseUrl is not defined.', async(): Promise => { + await expect(computer.handle({ port: 3333 })).resolves.toBe('http://localhost:3333/'); + }); + + it('defaults to port 3000.', async(): Promise => { + await expect(computer.handle({})).resolves.toBe('http://localhost:3000/'); + }); +}); diff --git a/test/unit/init/variables/extractors/KeyExtractor.test.ts b/test/unit/init/variables/extractors/KeyExtractor.test.ts new file mode 100644 index 000000000..21a25ce09 --- /dev/null +++ b/test/unit/init/variables/extractors/KeyExtractor.test.ts @@ -0,0 +1,19 @@ +import { KeyExtractor } from '../../../../../src/init/variables/extractors/KeyExtractor'; + +describe('An KeyExtractor', (): void => { + const key = 'test'; + let extractor: KeyExtractor; + + beforeEach(async(): Promise => { + extractor = new KeyExtractor(key); + }); + + it('extracts the value.', async(): Promise => { + await expect(extractor.handle({ test: 'data', notTest: 'notData' })).resolves.toBe('data'); + }); + + it('defaults to a given value if none is defined.', async(): Promise => { + extractor = new KeyExtractor(key, 'defaultData'); + await expect(extractor.handle({ notTest: 'notData' })).resolves.toBe('defaultData'); + }); +}); From 8f8e8e6df4a4a5d8759c95c2a07e457050830ed6 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Mon, 15 Nov 2021 14:36:57 +0100 Subject: [PATCH 20/39] feat: Send reset password recordId as query parameter This is a revert of a previous change but is now possible due to the use of JSON bodies. This does mean JavaScript is required in the HTML page, but that will be required for future changes anyway. --- .../handler/interaction/routes/reset-password.json | 2 +- .../email-password/handler/ForgotPasswordHandler.ts | 3 ++- .../email-password/handler/ResetPasswordHandler.ts | 4 +--- templates/identity/email-password/reset-password.html.ejs | 7 +++++++ test/integration/Identity.test.ts | 6 +++++- .../email-password/handler/ForgotPasswordHandler.test.ts | 2 +- .../email-password/handler/ResetPasswordHandler.test.ts | 8 ++++---- 7 files changed, 21 insertions(+), 11 deletions(-) diff --git a/config/identity/handler/interaction/routes/reset-password.json b/config/identity/handler/interaction/routes/reset-password.json index c4cdd7644..9a48ad540 100644 --- a/config/identity/handler/interaction/routes/reset-password.json +++ b/config/identity/handler/interaction/routes/reset-password.json @@ -6,7 +6,7 @@ "comment": "Handles the reset password page submission", "@id": "urn:solid-server:auth:password:ResetPasswordRoute", "@type": "BasicInteractionRoute", - "route": "^/resetpassword/[^/]*$", + "route": "^/resetpassword/$", "viewTemplates": { "BasicInteractionRoute:_viewTemplates_key": "text/html", "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/reset-password.html.ejs" diff --git a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts index 7e64f6c50..e536db2d9 100644 --- a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts @@ -68,7 +68,8 @@ export class ForgotPasswordHandler extends InteractionHandler { */ private async sendResetMail(recordId: string, email: string): Promise { this.logger.info(`Sending password reset to ${email}`); - const resetLink = joinUrl(this.baseUrl, this.idpPath, `resetpassword/${recordId}`); + // `joinUrl` strips trailing slash when query parameter gets added + const resetLink = `${joinUrl(this.baseUrl, this.idpPath, 'resetpassword/')}?rid=${recordId}`; const renderedEmail = await this.templateEngine.render({ resetLink }); await this.emailSender.handleSafe({ recipient: email, diff --git a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts index b2150ca20..431de8ea1 100644 --- a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts @@ -21,10 +21,8 @@ export class ResetPasswordHandler extends InteractionHandler { } public async handle({ operation }: InteractionHandlerInput): Promise { - // Extract record ID from request URL - const recordId = /\/([^/]+)$/u.exec(operation.target.path)?.[1]; // Validate input data - const { password, confirmPassword } = await readJsonStream(operation.body.data); + const { password, confirmPassword, recordId } = await readJsonStream(operation.body.data); assert( typeof recordId === 'string' && recordId.length > 0, 'Invalid request. Open the link from your email again', diff --git a/templates/identity/email-password/reset-password.html.ejs b/templates/identity/email-password/reset-password.html.ejs index a5c605eb8..69e9bbfc6 100644 --- a/templates/identity/email-password/reset-password.html.ejs +++ b/templates/identity/email-password/reset-password.html.ejs @@ -15,7 +15,14 @@ +

+ + diff --git a/test/integration/Identity.test.ts b/test/integration/Identity.test.ts index 36468643c..0561562e8 100644 --- a/test/integration/Identity.test.ts +++ b/test/integration/Identity.test.ts @@ -206,8 +206,12 @@ describe('A Solid server with IDP', (): void => { // Reset password form has no action causing the current URL to be used expect(relative).toBeUndefined(); + // Extract recordId from URL since JS is used to add it + const recordId = /\?rid=([^/]+)$/u.exec(nextUrl)?.[1]; + expect(typeof recordId).toBe('string'); + // POST the new password to the same URL - const formData = stringify({ password: password2, confirmPassword: password2 }); + const formData = stringify({ password: password2, confirmPassword: password2, recordId }); res = await fetch(nextUrl, { method: 'POST', headers: { 'content-type': APPLICATION_X_WWW_FORM_URLENCODED }, diff --git a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts index 4a7470571..f905e963c 100644 --- a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts @@ -64,7 +64,7 @@ describe('A ForgotPasswordHandler', (): void => { expect(emailSender.handleSafe).toHaveBeenLastCalledWith({ recipient: email, subject: 'Reset your password', - text: `To reset your password, go to this link: http://test.com/base/idp/resetpassword/${recordId}`, + text: `To reset your password, go to this link: http://test.com/base/idp/resetpassword/?rid=${recordId}`, html, }); }); diff --git a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts index 37f4c2176..73d7c851e 100644 --- a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts @@ -27,25 +27,25 @@ describe('A ResetPasswordHandler', (): void => { const errorMessage = 'Invalid request. Open the link from your email again'; operation = createPostJsonOperation({}); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); - operation = createPostJsonOperation({}, ''); + operation = createPostJsonOperation({ recordId: 5 }); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); }); it('errors for invalid passwords.', async(): Promise => { const errorMessage = 'Your password and confirmation did not match.'; - operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'otherPassword!' }, url); + operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'otherPassword!', recordId }, url); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); }); it('errors for invalid emails.', async(): Promise => { const errorMessage = 'This reset password link is no longer valid.'; - operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!' }, url); + operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!', recordId }, url); (accountStore.getForgotPasswordRecord as jest.Mock).mockResolvedValueOnce(undefined); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); }); it('renders a message on success.', async(): Promise => { - operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!' }, url); + operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!', recordId }, url); await expect(handler.handle({ operation })).resolves.toEqual({ type: 'response' }); expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1); expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId); From bc0eeb1012e15e9e9ee0f9085be209f6a9229ccd Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Thu, 2 Dec 2021 09:57:23 +0100 Subject: [PATCH 21/39] feat: Split up IDP HTML, routing, and handler behaviour --- config/identity/handler/default.json | 4 +- .../identity/handler/interaction/routes.json | 40 +++- .../interaction/routes/existing-login.json | 16 ++ .../interaction/routes/forgot-password.json | 26 +-- .../handler/interaction/routes/login.json | 18 +- .../handler/interaction/routes/prompt.json | 25 +++ .../interaction/routes/reset-password.json | 18 +- .../handler/interaction/routes/session.json | 20 -- .../handler/interaction/views/controls.json | 19 ++ .../handler/interaction/views/html.json | 43 ++++ config/identity/registration/enabled.json | 30 ++- .../registration/route/registration.json | 21 +- src/identity/IdentityProviderHttpHandler.ts | 186 +++--------------- .../configuration/IdentityProviderFactory.ts | 42 ++-- .../interaction/BaseInteractionHandler.ts | 51 +++++ .../CompletingInteractionHandler.ts | 15 +- src/identity/interaction/ControlHandler.ts | 43 ++++ ...HttpHandler.ts => ExistingLoginHandler.ts} | 6 +- src/identity/interaction/HtmlViewHandler.ts | 54 +++++ .../interaction/InteractionHandler.ts | 20 +- src/identity/interaction/PromptHandler.ts | 28 +++ .../handler/ForgotPasswordHandler.ts | 35 ++-- .../email-password/handler/LoginHandler.ts | 27 ++- .../handler/RegistrationHandler.ts | 35 +++- .../handler/ResetPasswordHandler.ts | 27 ++- .../routing/BasicInteractionRoute.ts | 118 +++-------- .../interaction/routing/InteractionRoute.ts | 30 +-- .../routing/RelativeInteractionRoute.ts | 18 ++ src/index.ts | 7 +- .../{confirm.html.ejs => consent.html.ejs} | 0 test/unit/identity/ControlHandler.test.ts | 52 +++++ .../IdentityProviderHttpHandler.test.ts | 159 ++++----------- .../IdentityProviderFactory.test.ts | 56 ++++-- .../BaseInteractionHandler.test.ts | 70 +++++++ .../CompletingInteractionHandler.test.ts | 21 +- ...r.test.ts => ExistingLoginHandler.test.ts} | 29 +-- .../interaction/HtmlViewHandler.test.ts | 68 +++++++ .../interaction/InteractionHandler.test.ts | 13 +- .../interaction/PromptHandler.test.ts | 37 ++++ .../handler/ForgotPasswordHandler.test.ts | 23 ++- .../handler/LoginHandler.test.ts | 35 ++-- .../handler/RegistrationHandler.test.ts | 8 +- .../handler/ResetPasswordHandler.test.ts | 5 +- .../routing/BasicInteractionRoute.test.ts | 101 ++++------ .../routing/RelativeInteractionRoute.test.ts | 30 +++ 45 files changed, 1013 insertions(+), 716 deletions(-) create mode 100644 config/identity/handler/interaction/routes/existing-login.json create mode 100644 config/identity/handler/interaction/routes/prompt.json delete mode 100644 config/identity/handler/interaction/routes/session.json create mode 100644 config/identity/handler/interaction/views/controls.json create mode 100644 config/identity/handler/interaction/views/html.json create mode 100644 src/identity/interaction/BaseInteractionHandler.ts create mode 100644 src/identity/interaction/ControlHandler.ts rename src/identity/interaction/{SessionHttpHandler.ts => ExistingLoginHandler.ts} (83%) create mode 100644 src/identity/interaction/HtmlViewHandler.ts create mode 100644 src/identity/interaction/PromptHandler.ts create mode 100644 src/identity/interaction/routing/RelativeInteractionRoute.ts rename templates/identity/email-password/{confirm.html.ejs => consent.html.ejs} (100%) create mode 100644 test/unit/identity/ControlHandler.test.ts create mode 100644 test/unit/identity/interaction/BaseInteractionHandler.test.ts rename test/unit/identity/interaction/{SessionHttpHandler.test.ts => ExistingLoginHandler.test.ts} (50%) create mode 100644 test/unit/identity/interaction/HtmlViewHandler.test.ts create mode 100644 test/unit/identity/interaction/PromptHandler.test.ts create mode 100644 test/unit/identity/interaction/routing/RelativeInteractionRoute.test.ts diff --git a/config/identity/handler/default.json b/config/identity/handler/default.json index 50fe0f71b..fd57f21cd 100644 --- a/config/identity/handler/default.json +++ b/config/identity/handler/default.json @@ -39,11 +39,9 @@ "comment": "Handles IDP handler behaviour.", "@id": "urn:solid-server:default:IdentityProviderHttpHandler", "@type": "IdentityProviderHttpHandler", - "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "args_idpPath": "/idp", "args_providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }, "args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, - "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" } + "args_handler": { "@id": "urn:solid-server:default:InteractionHandler" } } ] } diff --git a/config/identity/handler/interaction/routes.json b/config/identity/handler/interaction/routes.json index 83be9a153..034571015 100644 --- a/config/identity/handler/interaction/routes.json +++ b/config/identity/handler/interaction/routes.json @@ -1,19 +1,45 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", "import": [ + "files-scs:config/identity/handler/interaction/routes/existing-login.json", "files-scs:config/identity/handler/interaction/routes/forgot-password.json", "files-scs:config/identity/handler/interaction/routes/login.json", + "files-scs:config/identity/handler/interaction/routes/prompt.json", "files-scs:config/identity/handler/interaction/routes/reset-password.json", - "files-scs:config/identity/handler/interaction/routes/session.json" + "files-scs:config/identity/handler/interaction/views/controls.json", + "files-scs:config/identity/handler/interaction/views/html.json" ], "@graph": [ { - "@id": "urn:solid-server:default:IdentityProviderHttpHandler", - "IdentityProviderHttpHandler:_args_interactionRoutes": [ - { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" }, - { "@id": "urn:solid-server:auth:password:LoginRoute" }, - { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" }, - { "@id": "urn:solid-server:auth:password:SessionRoute" } + "@id": "urn:solid-server:default:InteractionHandler", + "@type": "WaterfallHandler", + "handlers": [ + { + "comment": "Returns the relevant HTML pages for the interactions when needed", + "@id": "urn:solid-server:auth:password:HtmlViewHandler" + }, + { + "comment": "Adds controls and API version to JSON responses.", + "@id": "urn:solid-server:auth:password:ControlHandler", + "ControlHandler:_source" : { + "@id": "urn:solid-server:auth:password:RouteInteractionHandler", + "@type": "WaterfallHandler", + "handlers": [ + { + "comment": [ + "This handler is required to prevent Components.js issues with arrays.", + "This might be fixed in the next Components.js release after which this can be removed." + ], + "@type": "UnsupportedAsyncHandler" + }, + { "@id": "urn:solid-server:auth:password:PromptRoute" }, + { "@id": "urn:solid-server:auth:password:LoginRoute" }, + { "@id": "urn:solid-server:auth:password:ExistingLoginRoute" }, + { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" }, + { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" } + ] + } + } ] } ] diff --git a/config/identity/handler/interaction/routes/existing-login.json b/config/identity/handler/interaction/routes/existing-login.json new file mode 100644 index 000000000..cb2baaf53 --- /dev/null +++ b/config/identity/handler/interaction/routes/existing-login.json @@ -0,0 +1,16 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles the interaction that occurs when a logged in user wants to authenticate with a new app.", + "@id": "urn:solid-server:auth:password:ExistingLoginRoute", + "@type": "RelativeInteractionRoute", + "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "relativePath": "/idp/consent/", + "source": { + "@type": "ExistingLoginHandler", + "interactionCompleter": { "@type": "BaseInteractionCompleter" } + } + } + ] +} diff --git a/config/identity/handler/interaction/routes/forgot-password.json b/config/identity/handler/interaction/routes/forgot-password.json index 3fe3ca6c5..946736770 100644 --- a/config/identity/handler/interaction/routes/forgot-password.json +++ b/config/identity/handler/interaction/routes/forgot-password.json @@ -2,32 +2,20 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", "@graph": [ { - "comment": "Handles all functionality on the forgot password page", + "comment": "Handles the forgot password interaction", "@id": "urn:solid-server:auth:password:ForgotPasswordRoute", - "@type": "BasicInteractionRoute", - "route": "^/forgotpassword/$", - "viewTemplates": { - "BasicInteractionRoute:_viewTemplates_key": "text/html", - "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/forgot-password.html.ejs" - }, - "responseTemplates": { - "BasicInteractionRoute:_responseTemplates_key": "text/html", - "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/forgot-password-response.html.ejs" - }, - "controls": { - "BasicInteractionRoute:_controls_key": "forgotPassword", - "BasicInteractionRoute:_controls_value": "/forgotpassword/" - }, - "handler": { + "@type": "RelativeInteractionRoute", + "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "relativePath": "/idp/forgotpassword/", + "source": { "@type": "ForgotPasswordHandler", "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, - "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "args_idpPath": "/idp", "args_templateEngine": { "@type": "EjsTemplateEngine", "template": "@css:templates/identity/email-password/reset-password-email.html.ejs" }, - "args_emailSender": { "@id": "urn:solid-server:default:EmailSender" } + "args_emailSender": { "@id": "urn:solid-server:default:EmailSender" }, + "args_resetRoute": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" } } } ] diff --git a/config/identity/handler/interaction/routes/login.json b/config/identity/handler/interaction/routes/login.json index 66f48dffd..bcd00ac67 100644 --- a/config/identity/handler/interaction/routes/login.json +++ b/config/identity/handler/interaction/routes/login.json @@ -2,20 +2,12 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", "@graph": [ { - "comment": "Handles all functionality on the Login Page", + "comment": "Handles the login interaction", "@id": "urn:solid-server:auth:password:LoginRoute", - "@type": "BasicInteractionRoute", - "route": "^/login/$", - "prompt": "login", - "viewTemplates": { - "BasicInteractionRoute:_viewTemplates_key": "text/html", - "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/login.html.ejs" - }, - "controls": { - "BasicInteractionRoute:_controls_key": "login", - "BasicInteractionRoute:_controls_value": "/login/" - }, - "handler": { + "@type": "RelativeInteractionRoute", + "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "relativePath": "/idp/login/", + "source": { "@type": "LoginHandler", "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, "interactionCompleter": { "@type": "BaseInteractionCompleter" } diff --git a/config/identity/handler/interaction/routes/prompt.json b/config/identity/handler/interaction/routes/prompt.json new file mode 100644 index 000000000..9e77dbfef --- /dev/null +++ b/config/identity/handler/interaction/routes/prompt.json @@ -0,0 +1,25 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles OIDC redirects containing a prompt, such as login or consent.", + "@id": "urn:solid-server:auth:password:PromptRoute", + "@type": "RelativeInteractionRoute", + "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "relativePath": "/idp/", + "source": { + "@type": "PromptHandler", + "promptRoutes": [ + { + "PromptHandler:_promptRoutes_key": "login", + "PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:auth:password:LoginRoute" } + }, + { + "PromptHandler:_promptRoutes_key": "consent", + "PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:auth:password:ExistingLoginRoute" } + } + ] + } + } + ] +} diff --git a/config/identity/handler/interaction/routes/reset-password.json b/config/identity/handler/interaction/routes/reset-password.json index 9a48ad540..a64a53c2e 100644 --- a/config/identity/handler/interaction/routes/reset-password.json +++ b/config/identity/handler/interaction/routes/reset-password.json @@ -1,21 +1,13 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", - "comment": "Exports 2 handlers: one for viewing the page and one for doing the reset.", "@graph": [ { - "comment": "Handles the reset password page submission", + "comment": "Handles the reset password interaction", "@id": "urn:solid-server:auth:password:ResetPasswordRoute", - "@type": "BasicInteractionRoute", - "route": "^/resetpassword/$", - "viewTemplates": { - "BasicInteractionRoute:_viewTemplates_key": "text/html", - "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/reset-password.html.ejs" - }, - "responseTemplates": { - "BasicInteractionRoute:_responseTemplates_key": "text/html", - "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/reset-password-response.html.ejs" - }, - "handler": { + "@type": "RelativeInteractionRoute", + "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "relativePath": "/idp/resetpassword/", + "source": { "@type": "ResetPasswordHandler", "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" } } diff --git a/config/identity/handler/interaction/routes/session.json b/config/identity/handler/interaction/routes/session.json deleted file mode 100644 index 90910688c..000000000 --- a/config/identity/handler/interaction/routes/session.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", - "@graph": [ - { - "comment": "Handles confirm requests", - "@id": "urn:solid-server:auth:password:SessionRoute", - "@type": "BasicInteractionRoute", - "route": "^/confirm/$", - "prompt": "consent", - "viewTemplates": { - "BasicInteractionRoute:_viewTemplates_key": "text/html", - "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/confirm.html.ejs" - }, - "handler": { - "@type": "SessionHttpHandler", - "interactionCompleter": { "@type": "BaseInteractionCompleter" } - } - } - ] -} diff --git a/config/identity/handler/interaction/views/controls.json b/config/identity/handler/interaction/views/controls.json new file mode 100644 index 000000000..50f11c451 --- /dev/null +++ b/config/identity/handler/interaction/views/controls.json @@ -0,0 +1,19 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@graph": [ + { + "@id": "urn:solid-server:auth:password:ControlHandler", + "@type": "ControlHandler", + "controls": [ + { + "ControlHandler:_controls_key": "login", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:LoginRoute" } + }, + { + "ControlHandler:_controls_key": "forgotPassword", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" } + } + ] + } + ] +} diff --git a/config/identity/handler/interaction/views/html.json b/config/identity/handler/interaction/views/html.json new file mode 100644 index 000000000..1ef478b55 --- /dev/null +++ b/config/identity/handler/interaction/views/html.json @@ -0,0 +1,43 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@graph": [ + { + "@id": "urn:solid-server:auth:password:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templateEngine": { + "comment": "Renders the specific page and embeds it into the main HTML body.", + "@type": "ChainedTemplateEngine", + "renderedName": "htmlBody", + "engines": [ + { + "comment": "Will be called with specific templates to generate HTML snippets.", + "@type": "EjsTemplateEngine" + }, + { + "comment": "Will embed the result of the first engine into the main HTML template.", + "@type": "EjsTemplateEngine", + "template": "@css:templates/main.html.ejs" + } + ] + }, + "templates": [ + { + "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/login.html.ejs", + "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:LoginRoute" } + }, + { + "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/consent.html.ejs", + "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ExistingLoginRoute" } + }, + { + "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/forgot-password.html.ejs", + "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" } + }, + { + "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/reset-password.html.ejs", + "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" } + } + ] + } + ] +} diff --git a/config/identity/registration/enabled.json b/config/identity/registration/enabled.json index 7ff5663b9..5bebf8d2b 100644 --- a/config/identity/registration/enabled.json +++ b/config/identity/registration/enabled.json @@ -5,11 +5,35 @@ ], "@graph": [ { - "comment": "Enable registration by adding a registration handler to the list of interaction routes.", - "@id": "urn:solid-server:default:IdentityProviderHttpHandler", - "IdentityProviderHttpHandler:_args_interactionRoutes": [ + "@id": "urn:solid-server:auth:password:RouteInteractionHandler", + "WaterfallHandler:_handlers": [ + { + "comment": [ + "This handler is required to prevent Components.js issues with arrays.", + "This might be fixed in the next Components.js release after which this can be removed." + ], + "@type": "UnsupportedAsyncHandler" + }, { "@id": "urn:solid-server:auth:password:RegistrationRoute" } ] + }, + { + "@id": "urn:solid-server:auth:password:ControlHandler", + "ControlHandler:_controls": [ + { + "ControlHandler:_controls_key": "register", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:RegistrationRoute" } + } + ] + }, + { + "@id": "urn:solid-server:auth:password:HtmlViewHandler", + "HtmlViewHandler:_templates": [ + { + "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/register.html.ejs", + "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:RegistrationRoute" } + } + ] } ] } diff --git a/config/identity/registration/route/registration.json b/config/identity/registration/route/registration.json index c0aff91af..37e18edd4 100644 --- a/config/identity/registration/route/registration.json +++ b/config/identity/registration/route/registration.json @@ -2,23 +2,12 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", "@graph": [ { - "comment": "Handles all functionality on the register page", + "comment": "Handles the register interaction", "@id": "urn:solid-server:auth:password:RegistrationRoute", - "@type": "BasicInteractionRoute", - "route": "^/register/$", - "viewTemplates": { - "BasicInteractionRoute:_viewTemplates_key": "text/html", - "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/register.html.ejs" - }, - "responseTemplates": { - "BasicInteractionRoute:_responseTemplates_key": "text/html", - "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/register-response.html.ejs" - }, - "controls": { - "BasicInteractionRoute:_controls_key": "register", - "BasicInteractionRoute:_controls_value": "/register/" - }, - "handler": { + "@type": "RelativeInteractionRoute", + "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "relativePath": "/idp/register/", + "source": { "@type": "RegistrationHandler", "registrationManager": { "@type": "RegistrationManager", diff --git a/src/identity/IdentityProviderHttpHandler.ts b/src/identity/IdentityProviderHttpHandler.ts index 174d5e68f..aa9f146ef 100644 --- a/src/identity/IdentityProviderHttpHandler.ts +++ b/src/identity/IdentityProviderHttpHandler.ts @@ -1,211 +1,81 @@ -import type { Operation } from '../http/Operation'; -import type { ErrorHandler } from '../http/output/error/ErrorHandler'; -import { ResponseDescription } from '../http/output/response/ResponseDescription'; -import { BasicRepresentation } from '../http/representation/BasicRepresentation'; +import { OkResponseDescription } from '../http/output/response/OkResponseDescription'; +import type { ResponseDescription } from '../http/output/response/ResponseDescription'; import { getLoggerFor } from '../logging/LogUtil'; import type { OperationHttpHandlerInput } from '../server/OperationHttpHandler'; import { OperationHttpHandler } from '../server/OperationHttpHandler'; import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter'; import { APPLICATION_JSON } from '../util/ContentTypes'; -import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; -import { joinUrl, trimTrailingSlashes } from '../util/PathUtil'; -import { addTemplateMetadata, cloneRepresentation } from '../util/ResourceUtil'; -import { readJsonStream } from '../util/StreamUtil'; import type { ProviderFactory } from './configuration/ProviderFactory'; -import type { Interaction } from './interaction/InteractionHandler'; -import type { InteractionRoute, TemplatedInteractionResult } from './interaction/routing/InteractionRoute'; - -const API_VERSION = '0.2'; +import type { + InteractionHandler, + Interaction, +} from './interaction/InteractionHandler'; export interface IdentityProviderHttpHandlerArgs { - /** - * Base URL of the server. - */ - baseUrl: string; - /** - * Relative path of the IDP entry point. - */ - idpPath: string; /** * Used to generate the OIDC provider. */ providerFactory: ProviderFactory; /** - * All routes handling the custom IDP behaviour. - */ - interactionRoutes: InteractionRoute[]; - /** - * Used for content negotiation. + * Used for converting the input data. */ converter: RepresentationConverter; /** - * Used for converting output errors. + * Handles the requests. */ - errorHandler: ErrorHandler; + handler: InteractionHandler; } /** - * Handles all requests relevant for the entire IDP interaction, - * by sending them to either a matching {@link InteractionRoute}, - * or the generated Provider from the {@link ProviderFactory} if there is no match. + * Generates the active Interaction object if there is an ongoing OIDC interaction + * and sends it to the {@link InteractionHandler}. * - * The InteractionRoutes handle all requests where we need custom behaviour, - * such as everything related to generating and validating an account. - * The Provider handles all the default request such as the initial handshake. + * Input data will first be converted to JSON. * - * This handler handles all requests since it assumes all those requests are relevant for the IDP interaction. - * A {@link RouterHandler} should be used to filter out other requests. + * Only GET and POST methods are accepted. */ export class IdentityProviderHttpHandler extends OperationHttpHandler { protected readonly logger = getLoggerFor(this); - private readonly baseUrl: string; private readonly providerFactory: ProviderFactory; - private readonly interactionRoutes: InteractionRoute[]; private readonly converter: RepresentationConverter; - private readonly errorHandler: ErrorHandler; - - private readonly controls: Record; + private readonly handler: InteractionHandler; public constructor(args: IdentityProviderHttpHandlerArgs) { super(); - // Trimming trailing slashes so the relative URL starts with a slash after slicing this off - this.baseUrl = trimTrailingSlashes(joinUrl(args.baseUrl, args.idpPath)); this.providerFactory = args.providerFactory; - this.interactionRoutes = args.interactionRoutes; this.converter = args.converter; - this.errorHandler = args.errorHandler; - - this.controls = Object.assign( - {}, - ...this.interactionRoutes.map((route): Record => this.getRouteControls(route)), - ); + this.handler = args.handler; } - /** - * Finds the matching route and resolves the operation. - */ public async handle({ operation, request, response }: OperationHttpHandlerInput): Promise { // This being defined means we're in an OIDC session let oidcInteraction: Interaction | undefined; try { const provider = await this.providerFactory.getProvider(); oidcInteraction = await provider.interactionDetails(request, response); + this.logger.debug('Found an active OIDC interaction.'); } catch { - // Just a regular request + this.logger.debug('No active OIDC interaction found.'); } - const route = await this.findRoute(operation, oidcInteraction); - if (!route) { - throw new NotFoundHttpError(); - } - - // Cloning input data so it can be sent back in case of errors - let clone = operation.body; - - // IDP handlers expect JSON data - if (!operation.body.isEmpty) { + // Convert input data to JSON + // Allows us to still support form data + const { contentType } = operation.body.metadata; + if (contentType && contentType !== APPLICATION_JSON) { + this.logger.debug(`Converting input ${contentType} to ${APPLICATION_JSON}`); const args = { representation: operation.body, preferences: { type: { [APPLICATION_JSON]: 1 }}, identifier: operation.target, }; - operation.body = await this.converter.handleSafe(args); - clone = await cloneRepresentation(operation.body); + operation = { + ...operation, + body: await this.converter.handleSafe(args), + }; } - const result = await route.handleOperation(operation, oidcInteraction); - - // Reset the body so it can be reused when needed for output - operation.body = clone; - - return this.handleInteractionResult(operation, result, oidcInteraction); - } - - /** - * Finds a route that supports the given request. - */ - private async findRoute(operation: Operation, oidcInteraction?: Interaction): Promise { - if (!operation.target.path.startsWith(this.baseUrl)) { - // This is an invalid request - return; - } - const pathName = operation.target.path.slice(this.baseUrl.length); - - for (const route of this.interactionRoutes) { - if (route.supportsPath(pathName, oidcInteraction?.prompt.name)) { - return route; - } - } - } - - /** - * 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, result: TemplatedInteractionResult, - oidcInteraction?: Interaction): Promise { - let responseDescription: ResponseDescription | undefined; - - if (result.type === 'error') { - // We want to show the errors on the original page in case of html interactions, so we can't just throw them here - const preferences = { type: { [APPLICATION_JSON]: 1 }}; - const response = await this.errorHandler.handleSafe({ error: result.error, preferences }); - const details = await readJsonStream(response.data!); - - // Add the input data to the JSON response; - if (!operation.body.isEmpty) { - details.prefilled = await readJsonStream(operation.body.data); - - // Don't send passwords back - delete details.prefilled.password; - delete details.prefilled.confirmPassword; - } - - responseDescription = - await this.handleResponseResult(details, operation, result.templateFiles, oidcInteraction, response.statusCode); - } else { - // Convert the response object to a data stream - responseDescription = - await this.handleResponseResult(result.details ?? {}, operation, result.templateFiles, oidcInteraction); - } - - return responseDescription; - } - - /** - * Converts an InteractionResponseResult to a ResponseDescription by first converting to a Representation - * and applying necessary conversions. - */ - private async handleResponseResult(details: Record, operation: Operation, - templateFiles: Record, oidcInteraction?: Interaction, statusCode = 200): - Promise { - const json = { - ...details, - apiVersion: API_VERSION, - authenticating: Boolean(oidcInteraction), - controls: this.controls, - }; - 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 ResponseDescription(statusCode, converted.metadata, converted.data); - } - - /** - * Converts the controls object of a route to one with full URLs. - */ - private getRouteControls(route: InteractionRoute): Record { - const entries = Object.entries(route.getControls()) - .map(([ name, path ]): [ string, string ] => [ name, joinUrl(this.baseUrl, path) ]); - return Object.fromEntries(entries); + const representation = await this.handler.handleSafe({ operation, oidcInteraction }); + return new OkResponseDescription(representation.metadata, representation.data); } } diff --git a/src/identity/configuration/IdentityProviderFactory.ts b/src/identity/configuration/IdentityProviderFactory.ts index 4717c8dae..fc3125422 100644 --- a/src/identity/configuration/IdentityProviderFactory.ts +++ b/src/identity/configuration/IdentityProviderFactory.ts @@ -12,10 +12,15 @@ import type { AnyObject, ErrorOut, Adapter } from 'oidc-provider'; import { Provider } from 'oidc-provider'; +import type { Operation } from '../../http/Operation'; import type { ErrorHandler } from '../../http/output/error/ErrorHandler'; import type { ResponseWriter } from '../../http/output/ResponseWriter'; +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage'; -import { ensureTrailingSlash, joinUrl } from '../../util/PathUtil'; +import { InternalServerError } from '../../util/errors/InternalServerError'; +import { RedirectHttpError } from '../../util/errors/RedirectHttpError'; +import { joinUrl } from '../../util/PathUtil'; +import type { InteractionHandler } from '../interaction/InteractionHandler'; import type { AdapterFactory } from '../storage/AdapterFactory'; import type { ProviderFactory } from './ProviderFactory'; @@ -33,10 +38,9 @@ export interface IdentityProviderFactoryArgs { */ oidcPath: string; /** - * The entry point for the custom IDP handlers of the server. - * Should start with a slash. + * The handler responsible for redirecting interaction requests to the correct URL. */ - idpPath: string; + interactionHandler: InteractionHandler; /** * Storage used to store cookie and JWT keys so they can be re-used in case of multithreading. */ @@ -59,14 +63,14 @@ const COOKIES_KEY = 'cookie-secret'; * The provider will be cached and returned on subsequent calls. * Cookie and JWT keys will be stored in an internal storage so they can be re-used over multiple threads. * Necessary claims for Solid OIDC interactions will be added. - * Routes will be updated based on the `baseUrl` and `idpPath`. + * Routes will be updated based on the `baseUrl` and `oidcPath`. */ export class IdentityProviderFactory implements ProviderFactory { private readonly config: Configuration; private readonly adapterFactory!: AdapterFactory; private readonly baseUrl!: string; private readonly oidcPath!: string; - private readonly idpPath!: string; + private readonly interactionHandler!: InteractionHandler; private readonly storage!: KeyValueStorage; private readonly errorHandler!: ErrorHandler; private readonly responseWriter!: ResponseWriter; @@ -78,9 +82,6 @@ export class IdentityProviderFactory implements ProviderFactory { * @param args - Remaining parameters required for the factory. */ public constructor(config: Configuration, args: IdentityProviderFactoryArgs) { - if (!args.idpPath.startsWith('/')) { - throw new Error('idpPath needs to start with a /'); - } this.config = config; Object.assign(this, args); } @@ -230,7 +231,26 @@ export class IdentityProviderFactory implements ProviderFactory { // (missing user session, requested ACR not fulfilled, prompt requested, ...) // it will resolve the interactions.url helper function and redirect the User-Agent to that url. config.interactions = { - url: (): string => ensureTrailingSlash(this.idpPath), + url: async(ctx, oidcInteraction): Promise => { + const operation: Operation = { + method: ctx.method, + target: { path: ctx.request.href }, + preferences: {}, + body: new BasicRepresentation(), + }; + + // Instead of sending a 3xx redirect to the client (via a RedirectHttpError), + // we need to pass the location URL to the OIDC library + try { + await this.interactionHandler.handleSafe({ operation, oidcInteraction }); + } catch (error: unknown) { + if (RedirectHttpError.isInstance(error)) { + return error.location; + } + throw error; + } + throw new InternalServerError('Could not correctly redirect for the given interaction.'); + }, }; config.routes = { @@ -254,7 +274,7 @@ export class IdentityProviderFactory implements ProviderFactory { */ private configureErrors(config: Configuration): void { config.renderError = async(ctx: KoaContextWithOIDC, out: ErrorOut, error: Error): Promise => { - // This allows us to stream directly to to the response object, see https://github.com/koajs/koa/issues/944 + // This allows us to stream directly to the response object, see https://github.com/koajs/koa/issues/944 ctx.respond = false; const result = await this.errorHandler.handleSafe({ error, preferences: { type: { 'text/plain': 1 }}}); await this.responseWriter.handleSafe({ response: ctx.res, result }); diff --git a/src/identity/interaction/BaseInteractionHandler.ts b/src/identity/interaction/BaseInteractionHandler.ts new file mode 100644 index 000000000..e81afe7a2 --- /dev/null +++ b/src/identity/interaction/BaseInteractionHandler.ts @@ -0,0 +1,51 @@ +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import { InteractionHandler } from './InteractionHandler'; + +/** + * Abstract implementation for handlers that always return a fixed JSON view on a GET. + * POST requests are passed to an abstract function. + * Other methods will be rejected. + */ +export abstract class BaseInteractionHandler extends InteractionHandler { + private readonly view: string; + + protected constructor(view: Record) { + super(); + this.view = JSON.stringify(view); + } + + public async canHandle(input: InteractionHandlerInput): Promise { + await super.canHandle(input); + const { method } = input.operation; + if (method !== 'GET' && method !== 'POST') { + throw new MethodNotAllowedHttpError('Only GET/POST requests are supported.'); + } + } + + public async handle(input: InteractionHandlerInput): Promise { + switch (input.operation.method) { + case 'GET': return this.handleGet(input); + case 'POST': return this.handlePost(input); + default: throw new MethodNotAllowedHttpError(); + } + } + + /** + * Returns a fixed JSON view. + * @param input - Input parameters, only the operation target is used. + */ + protected async handleGet(input: InteractionHandlerInput): Promise { + return new BasicRepresentation(this.view, input.operation.target, APPLICATION_JSON); + } + + /** + * Function that will be called for POST requests. + * Input data remains unchanged. + * @param input - Input operation and OidcInteraction if it exists. + */ + protected abstract handlePost(input: InteractionHandlerInput): Promise; +} diff --git a/src/identity/interaction/CompletingInteractionHandler.ts b/src/identity/interaction/CompletingInteractionHandler.ts index 5b30f2698..3a460011e 100644 --- a/src/identity/interaction/CompletingInteractionHandler.ts +++ b/src/identity/interaction/CompletingInteractionHandler.ts @@ -1,11 +1,11 @@ import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; import { FoundHttpError } from '../../util/errors/FoundHttpError'; +import { BaseInteractionHandler } from './BaseInteractionHandler'; import type { InteractionHandlerInput } from './InteractionHandler'; -import { InteractionHandler } from './InteractionHandler'; import type { InteractionCompleterInput, InteractionCompleter } from './util/InteractionCompleter'; /** - * Abstract class for {@link InteractionHandler}s that need to call an {@link InteractionCompleter}. + * Abstract extension of {@link BaseInteractionHandler} for handlers that need to call an {@link InteractionCompleter}. * This is required by handlers that handle IDP behaviour * and need to complete an OIDC interaction by redirecting back to the client, * such as when logging in. @@ -13,17 +13,17 @@ import type { InteractionCompleterInput, InteractionCompleter } from './util/Int * Calls the InteractionCompleter with the results returned by the helper function * and throw a corresponding {@link FoundHttpError}. */ -export abstract class CompletingInteractionHandler extends InteractionHandler { +export abstract class CompletingInteractionHandler extends BaseInteractionHandler { protected readonly interactionCompleter: InteractionCompleter; - protected constructor(interactionCompleter: InteractionCompleter) { - super(); + protected constructor(view: Record, interactionCompleter: InteractionCompleter) { + super(view); this.interactionCompleter = interactionCompleter; } public async canHandle(input: InteractionHandlerInput): Promise { await super.canHandle(input); - if (!input.oidcInteraction) { + if (input.operation.method === 'POST' && !input.oidcInteraction) { throw new BadRequestHttpError( 'This action can only be performed as part of an OIDC authentication flow.', { errorCode: 'E0002' }, @@ -31,7 +31,7 @@ export abstract class CompletingInteractionHandler extends InteractionHandler { } } - public async handle(input: InteractionHandlerInput): Promise { + public async handlePost(input: InteractionHandlerInput): Promise { // Interaction is defined due to canHandle call const parameters = await this.getCompletionParameters(input as Required); const location = await this.interactionCompleter.handleSafe(parameters); @@ -40,6 +40,7 @@ export abstract class CompletingInteractionHandler extends InteractionHandler { /** * Generates the parameters necessary to call an InteractionCompleter. + * The input parameters are the same that the `handlePost` function was called with. * @param input - The original input parameters to the `handle` function. */ protected abstract getCompletionParameters(input: Required): diff --git a/src/identity/interaction/ControlHandler.ts b/src/identity/interaction/ControlHandler.ts new file mode 100644 index 000000000..34eb78500 --- /dev/null +++ b/src/identity/interaction/ControlHandler.ts @@ -0,0 +1,43 @@ +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import { InternalServerError } from '../../util/errors/InternalServerError'; +import { readJsonStream } from '../../util/StreamUtil'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import { InteractionHandler } from './InteractionHandler'; +import type { InteractionRoute } from './routing/InteractionRoute'; + +const INTERNAL_API_VERSION = '0.3'; + +/** + * Adds `controls` and `apiVersion` fields to the output of its source handler, + * such that clients can predictably find their way to other resources. + * Control paths are determined by the input routes. + */ +export class ControlHandler extends InteractionHandler { + private readonly source: InteractionHandler; + private readonly controls: Record; + + public constructor(source: InteractionHandler, controls: Record) { + super(); + this.source = source; + this.controls = Object.fromEntries( + Object.entries(controls).map(([ control, route ]): [ string, string ] => [ control, route.getPath() ]), + ); + } + + public async canHandle(input: InteractionHandlerInput): Promise { + await this.source.canHandle(input); + } + + public async handle(input: InteractionHandlerInput): Promise { + const result = await this.source.handle(input); + if (result.metadata.contentType !== APPLICATION_JSON) { + throw new InternalServerError('Source handler should return application/json.'); + } + const json = await readJsonStream(result.data); + json.controls = this.controls; + json.apiVersion = INTERNAL_API_VERSION; + return new BasicRepresentation(JSON.stringify(json), result.metadata); + } +} diff --git a/src/identity/interaction/SessionHttpHandler.ts b/src/identity/interaction/ExistingLoginHandler.ts similarity index 83% rename from src/identity/interaction/SessionHttpHandler.ts rename to src/identity/interaction/ExistingLoginHandler.ts index 5e26f88fd..94755405f 100644 --- a/src/identity/interaction/SessionHttpHandler.ts +++ b/src/identity/interaction/ExistingLoginHandler.ts @@ -5,12 +5,12 @@ import type { InteractionHandlerInput } from './InteractionHandler'; import type { InteractionCompleter, InteractionCompleterInput } from './util/InteractionCompleter'; /** - * Simple InteractionHttpHandler that sends the session accountId to the InteractionCompleter as webId. + * Simple CompletingInteractionRoute that returns the session accountId as webId. * This is relevant when a client already logged in this session and tries logging in again. */ -export class SessionHttpHandler extends CompletingInteractionHandler { +export class ExistingLoginHandler extends CompletingInteractionHandler { public constructor(interactionCompleter: InteractionCompleter) { - super(interactionCompleter); + super({}, interactionCompleter); } protected async getCompletionParameters({ operation, oidcInteraction }: Required): diff --git a/src/identity/interaction/HtmlViewHandler.ts b/src/identity/interaction/HtmlViewHandler.ts new file mode 100644 index 000000000..a9d304f7b --- /dev/null +++ b/src/identity/interaction/HtmlViewHandler.ts @@ -0,0 +1,54 @@ +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { cleanPreferences, getTypeWeight } from '../../storage/conversion/ConversionUtil'; +import { APPLICATION_JSON, TEXT_HTML } from '../../util/ContentTypes'; +import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError'; +import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import type { TemplateEngine } from '../../util/templates/TemplateEngine'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import { InteractionHandler } from './InteractionHandler'; +import type { InteractionRoute } from './routing/InteractionRoute'; + +/** + * Stores the HTML templates associated with specific InteractionRoutes. + * Template keys should be file paths to the templates, + * values should be the corresponding routes. + * + * Will only handle GET operations for which there is a matching template if HTML is more preferred than JSON. + * Reason for doing it like this instead of a standard content negotiation flow + * is because we only want to return the HTML pages on GET requests. * + */ +export class HtmlViewHandler extends InteractionHandler { + private readonly templateEngine: TemplateEngine; + private readonly templates: Record; + + public constructor(templateEngine: TemplateEngine, templates: Record) { + super(); + this.templateEngine = templateEngine; + this.templates = Object.fromEntries( + Object.entries(templates).map(([ template, route ]): [ string, string ] => [ route.getPath(), template ]), + ); + } + + public async canHandle({ operation }: InteractionHandlerInput): Promise { + if (operation.method !== 'GET') { + throw new MethodNotAllowedHttpError(); + } + if (!this.templates[operation.target.path]) { + throw new NotFoundHttpError(); + } + const preferences = cleanPreferences(operation.preferences.type); + const htmlWeight = getTypeWeight(TEXT_HTML, preferences); + const jsonWeight = getTypeWeight(APPLICATION_JSON, preferences); + if (jsonWeight >= htmlWeight) { + throw new NotImplementedHttpError('HTML views are only returned when they are preferred.'); + } + } + + public async handle({ operation }: InteractionHandlerInput): Promise { + const template = this.templates[operation.target.path]; + const result = await this.templateEngine.render({}, { templateFile: template }); + return new BasicRepresentation(result, operation.target, TEXT_HTML); + } +} diff --git a/src/identity/interaction/InteractionHandler.ts b/src/identity/interaction/InteractionHandler.ts index 5bab30adf..3bef6f451 100644 --- a/src/identity/interaction/InteractionHandler.ts +++ b/src/identity/interaction/InteractionHandler.ts @@ -1,5 +1,6 @@ import type { KoaContextWithOIDC } from 'oidc-provider'; import type { Operation } from '../../http/Operation'; +import type { Representation } from '../../http/representation/Representation'; import { APPLICATION_JSON } from '../../util/ContentTypes'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { AsyncHandler } from '../../util/handlers/AsyncHandler'; @@ -9,7 +10,7 @@ export type Interaction = NonNullable> { - type: 'response'; - details?: T; -} - -export interface InteractionErrorResult { - type: 'error'; - error: Error; -} - /** * Handler used for IDP interactions. * Only supports JSON data. */ -export abstract class InteractionHandler extends AsyncHandler { +export abstract class InteractionHandler extends AsyncHandler { public async canHandle({ operation }: InteractionHandlerInput): Promise { - if (operation.body?.metadata.contentType !== APPLICATION_JSON) { + const { contentType } = operation.body.metadata; + if (contentType && contentType !== APPLICATION_JSON) { throw new NotImplementedHttpError('Only application/json data is supported.'); } } diff --git a/src/identity/interaction/PromptHandler.ts b/src/identity/interaction/PromptHandler.ts new file mode 100644 index 000000000..1eb838204 --- /dev/null +++ b/src/identity/interaction/PromptHandler.ts @@ -0,0 +1,28 @@ +import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; +import { FoundHttpError } from '../../util/errors/FoundHttpError'; +import { InteractionHandler } from './InteractionHandler'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import type { InteractionRoute } from './routing/InteractionRoute'; + +/** + * Redirects requests based on the OIDC Interaction prompt. + * Errors in case no match was found. + */ +export class PromptHandler extends InteractionHandler { + private readonly promptRoutes: Record; + + public constructor(promptRoutes: Record) { + super(); + this.promptRoutes = promptRoutes; + } + + public async handle({ oidcInteraction }: InteractionHandlerInput): Promise { + // We also want to redirect on GET so no method check is needed + const prompt = oidcInteraction?.prompt.name; + if (prompt && this.promptRoutes[prompt]) { + const location = this.promptRoutes[prompt].getPath(); + throw new FoundHttpError(location); + } + throw new BadRequestHttpError(`Unsupported prompt: ${prompt}`); + } +} diff --git a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts index e536db2d9..a4ad43ebc 100644 --- a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts @@ -1,49 +1,55 @@ import assert from 'assert'; +import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; +import type { Representation } from '../../../../http/representation/Representation'; import { getLoggerFor } from '../../../../logging/LogUtil'; -import { ensureTrailingSlash, joinUrl } from '../../../../util/PathUtil'; +import { APPLICATION_JSON } from '../../../../util/ContentTypes'; import { readJsonStream } from '../../../../util/StreamUtil'; import type { TemplateEngine } from '../../../../util/templates/TemplateEngine'; -import { InteractionHandler } from '../../InteractionHandler'; -import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler'; +import { BaseInteractionHandler } from '../../BaseInteractionHandler'; +import type { InteractionHandlerInput } from '../../InteractionHandler'; +import type { InteractionRoute } from '../../routing/InteractionRoute'; import type { EmailSender } from '../../util/EmailSender'; import type { AccountStore } from '../storage/AccountStore'; +const forgotPasswordView = { + required: { + email: 'string', + }, +} as const; + export interface ForgotPasswordHandlerArgs { accountStore: AccountStore; - baseUrl: string; - idpPath: string; templateEngine: TemplateEngine<{ resetLink: string }>; emailSender: EmailSender; + resetRoute: InteractionRoute; } /** * Handles the submission of the ForgotPassword form */ -export class ForgotPasswordHandler extends InteractionHandler { +export class ForgotPasswordHandler extends BaseInteractionHandler { protected readonly logger = getLoggerFor(this); private readonly accountStore: AccountStore; - private readonly baseUrl: string; - private readonly idpPath: string; private readonly templateEngine: TemplateEngine<{ resetLink: string }>; private readonly emailSender: EmailSender; + private readonly resetRoute: InteractionRoute; public constructor(args: ForgotPasswordHandlerArgs) { - super(); + super(forgotPasswordView); this.accountStore = args.accountStore; - this.baseUrl = ensureTrailingSlash(args.baseUrl); - this.idpPath = args.idpPath; this.templateEngine = args.templateEngine; this.emailSender = args.emailSender; + this.resetRoute = args.resetRoute; } - public async handle({ operation }: InteractionHandlerInput): Promise> { + public async handlePost({ operation }: InteractionHandlerInput): Promise { // Validate incoming data const { email } = await readJsonStream(operation.body.data); assert(typeof email === 'string' && email.length > 0, 'Email required'); await this.resetPassword(email); - return { type: 'response', details: { email }}; + return new BasicRepresentation(JSON.stringify({ email }), operation.target, APPLICATION_JSON); } /** @@ -68,8 +74,7 @@ export class ForgotPasswordHandler extends InteractionHandler { */ private async sendResetMail(recordId: string, email: string): Promise { this.logger.info(`Sending password reset to ${email}`); - // `joinUrl` strips trailing slash when query parameter gets added - const resetLink = `${joinUrl(this.baseUrl, this.idpPath, 'resetpassword/')}?rid=${recordId}`; + const resetLink = `${this.resetRoute.getPath()}?rid=${encodeURIComponent(recordId)}`; const renderedEmail = await this.templateEngine.render({ resetLink }); await this.emailSender.handleSafe({ recipient: email, diff --git a/src/identity/interaction/email-password/handler/LoginHandler.ts b/src/identity/interaction/email-password/handler/LoginHandler.ts index 2ef26889f..0f8e9f04c 100644 --- a/src/identity/interaction/email-password/handler/LoginHandler.ts +++ b/src/identity/interaction/email-password/handler/LoginHandler.ts @@ -6,9 +6,22 @@ import { readJsonStream } from '../../../../util/StreamUtil'; import { CompletingInteractionHandler } from '../../CompletingInteractionHandler'; import type { InteractionHandlerInput } from '../../InteractionHandler'; import type { InteractionCompleterInput, InteractionCompleter } from '../../util/InteractionCompleter'; - import type { AccountStore } from '../storage/AccountStore'; +const loginView = { + required: { + email: 'string', + password: 'string', + remember: 'boolean', + }, +} as const; + +interface LoginInput { + email: string; + password: string; + remember: boolean; +} + /** * Handles the submission of the Login Form and logs the user in. * Will throw a RedirectHttpError on success. @@ -19,12 +32,13 @@ export class LoginHandler extends CompletingInteractionHandler { private readonly accountStore: AccountStore; public constructor(accountStore: AccountStore, interactionCompleter: InteractionCompleter) { - super(interactionCompleter); + super(loginView, interactionCompleter); this.accountStore = accountStore; } - protected async getCompletionParameters({ operation, oidcInteraction }: Required): + protected async getCompletionParameters(input: Required): Promise { + const { operation, oidcInteraction } = input; const { email, password, remember } = await this.parseInput(operation); // Try to log in, will error if email/password combination is invalid const webId = await this.accountStore.authenticate(email, password); @@ -39,15 +53,12 @@ export class LoginHandler extends CompletingInteractionHandler { } /** - * Parses and validates the input form data. + * Validates the input data. Also makes sure remember is a boolean. * Will throw an error in case something is wrong. - * All relevant data that was correct up to that point will be prefilled. */ - private async parseInput(operation: Operation): Promise<{ email: string; password: string; remember: boolean }> { - const prefilled: Record = {}; + private async parseInput(operation: Operation): Promise { const { email, password, remember } = await readJsonStream(operation.body.data); assert(typeof email === 'string' && email.length > 0, 'Email required'); - prefilled.email = email; assert(typeof password === 'string' && password.length > 0, 'Password required'); return { email, password, remember: Boolean(remember) }; } diff --git a/src/identity/interaction/email-password/handler/RegistrationHandler.ts b/src/identity/interaction/email-password/handler/RegistrationHandler.ts index 16db7c395..a8ae66b5c 100644 --- a/src/identity/interaction/email-password/handler/RegistrationHandler.ts +++ b/src/identity/interaction/email-password/handler/RegistrationHandler.ts @@ -1,27 +1,46 @@ +import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; +import type { Representation } from '../../../../http/representation/Representation'; import { getLoggerFor } from '../../../../logging/LogUtil'; +import { APPLICATION_JSON } from '../../../../util/ContentTypes'; import { readJsonStream } from '../../../../util/StreamUtil'; -import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler'; -import { InteractionHandler } from '../../InteractionHandler'; -import type { RegistrationManager, RegistrationResponse } from '../util/RegistrationManager'; +import { BaseInteractionHandler } from '../../BaseInteractionHandler'; +import type { InteractionHandlerInput } from '../../InteractionHandler'; +import type { RegistrationManager } from '../util/RegistrationManager'; + +const registrationView = { + required: { + email: 'string', + password: 'string', + confirmPassword: 'string', + createWebId: 'boolean', + register: 'boolean', + createPod: 'boolean', + rootPod: 'boolean', + }, + optional: { + webId: 'string', + podName: 'string', + template: 'string', + }, +} as const; /** * Supports registration based on the `RegistrationManager` behaviour. */ -export class RegistrationHandler extends InteractionHandler { +export class RegistrationHandler extends BaseInteractionHandler { protected readonly logger = getLoggerFor(this); private readonly registrationManager: RegistrationManager; public constructor(registrationManager: RegistrationManager) { - super(); + super(registrationView); this.registrationManager = registrationManager; } - public async handle({ operation }: InteractionHandlerInput): - Promise> { + public async handlePost({ operation }: InteractionHandlerInput): Promise { const data = await readJsonStream(operation.body.data); const validated = this.registrationManager.validateInput(data, false); const details = await this.registrationManager.register(validated, false); - return { type: 'response', details }; + return new BasicRepresentation(JSON.stringify(details), operation.target, APPLICATION_JSON); } } diff --git a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts index 431de8ea1..5f9fe520e 100644 --- a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts @@ -1,26 +1,37 @@ import assert from 'assert'; +import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; +import type { Representation } from '../../../../http/representation/Representation'; import { getLoggerFor } from '../../../../logging/LogUtil'; +import { APPLICATION_JSON } from '../../../../util/ContentTypes'; import { readJsonStream } from '../../../../util/StreamUtil'; -import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler'; -import { InteractionHandler } from '../../InteractionHandler'; +import { BaseInteractionHandler } from '../../BaseInteractionHandler'; +import type { InteractionHandlerInput } from '../../InteractionHandler'; import { assertPassword } from '../EmailPasswordUtil'; import type { AccountStore } from '../storage/AccountStore'; +const resetPasswordView = { + required: { + password: 'string', + confirmPassword: 'string', + recordId: 'string', + }, +} as const; + /** - * Handles the submission of the ResetPassword form: - * this is the form that is linked in the reset password email. + * Resets a password if a valid `recordId` is provided, + * which should have been generated by a different handler. */ -export class ResetPasswordHandler extends InteractionHandler { +export class ResetPasswordHandler extends BaseInteractionHandler { protected readonly logger = getLoggerFor(this); private readonly accountStore: AccountStore; public constructor(accountStore: AccountStore) { - super(); + super(resetPasswordView); this.accountStore = accountStore; } - public async handle({ operation }: InteractionHandlerInput): Promise { + public async handlePost({ operation }: InteractionHandlerInput): Promise { // Validate input data const { password, confirmPassword, recordId } = await readJsonStream(operation.body.data); assert( @@ -30,7 +41,7 @@ export class ResetPasswordHandler extends InteractionHandler { assertPassword(password, confirmPassword); await this.resetPassword(recordId, password); - return { type: 'response' }; + return new BasicRepresentation(JSON.stringify({}), operation.target, APPLICATION_JSON); } /** diff --git a/src/identity/interaction/routing/BasicInteractionRoute.ts b/src/identity/interaction/routing/BasicInteractionRoute.ts index 580d3dd12..f726e687e 100644 --- a/src/identity/interaction/routing/BasicInteractionRoute.ts +++ b/src/identity/interaction/routing/BasicInteractionRoute.ts @@ -1,101 +1,43 @@ -import type { Operation } from '../../../http/Operation'; -import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; -import { createErrorMessage, isError } from '../../../util/errors/ErrorUtil'; -import { InternalServerError } from '../../../util/errors/InternalServerError'; -import { RedirectHttpError } from '../../../util/errors/RedirectHttpError'; -import { trimTrailingSlashes } from '../../../util/PathUtil'; -import type { - InteractionHandler, - Interaction, -} from '../InteractionHandler'; -import type { InteractionRoute, TemplatedInteractionResult } from './InteractionRoute'; +import type { Representation } from '../../../http/representation/Representation'; +import { NotFoundHttpError } from '../../../util/errors/NotFoundHttpError'; +import { UnsupportedAsyncHandler } from '../../../util/handlers/UnsupportedAsyncHandler'; +import { InteractionHandler } from '../InteractionHandler'; +import type { InteractionHandlerInput } from '../InteractionHandler'; +import type { InteractionRoute } from './InteractionRoute'; /** - * Default implementation of the InteractionRoute. - * See function comments for specifics. + * Default implementation of an InteractionHandler with an InteractionRoute. + * + * Rejects operations that target a different path, + * otherwise the input parameters get passed to the source handler. + * + * In case no source handler is provided it defaults to an {@link UnsupportedAsyncHandler}. + * This can be useful if you want an object with just the route. */ -export class BasicInteractionRoute implements InteractionRoute { - public readonly route: RegExp; - public readonly handler: InteractionHandler; - public readonly viewTemplates: Record; - public readonly prompt?: string; - public readonly responseTemplates: Record; - public readonly controls: Record; +export class BasicInteractionRoute extends InteractionHandler implements InteractionRoute { + private readonly path: string; + private readonly source: InteractionHandler; - /** - * @param route - Regex to match this route. - * @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. - * @param responseTemplates - Templates to render as a response to POST requests when required. - * Keys are content-types, values paths to a template. - * @param controls - Controls to add to the response JSON. - * The keys will be copied and the values will be converted to full URLs. - */ - public constructor(route: string, - viewTemplates: Record, - handler: InteractionHandler, - prompt?: string, - responseTemplates: Record = {}, - controls: Record = {}) { - this.route = new RegExp(route, 'u'); - this.viewTemplates = viewTemplates; - this.handler = handler; - this.prompt = prompt; - this.responseTemplates = responseTemplates; - this.controls = controls; + public constructor(path: string, source?: InteractionHandler) { + super(); + this.path = path; + this.source = source ?? new UnsupportedAsyncHandler('This route has no associated handler.'); } - /** - * Returns the stored controls. - */ - public getControls(): Record { - return this.controls; + public getPath(): string { + return this.path; } - /** - * Checks support by comparing the prompt if the path targets the base URL, - * and otherwise comparing with the stored route regular expression. - */ - public supportsPath(path: string, prompt?: string): boolean { - // In case the request targets the IDP entry point the prompt determines where to go - if (trimTrailingSlashes(path).length === 0 && prompt) { - return this.prompt === prompt; + public async canHandle(input: InteractionHandlerInput): Promise { + const { target } = input.operation; + const path = this.getPath(); + if (target.path !== path) { + throw new NotFoundHttpError(); } - return this.route.test(path); + await this.source.canHandle(input); } - /** - * GET requests return a default response result. - * POST requests return the InteractionHandler result. - * InteractionHandler errors will be converted into response results. - * - * All results will be appended with the matching template paths. - * - * Will error for other methods - */ - public async handleOperation(operation: Operation, oidcInteraction?: Interaction): - Promise { - switch (operation.method) { - case 'GET': - return { type: 'response', templateFiles: this.viewTemplates }; - case 'POST': - try { - const result = await this.handler.handleSafe({ operation, oidcInteraction }); - return { ...result, templateFiles: this.responseTemplates }; - } catch (err: unknown) { - // Redirect errors need to be propagated and not rendered on the response pages. - // Otherwise, the user would be redirected to a new page only containing that error. - if (RedirectHttpError.isInstance(err)) { - throw err; - } - const error = isError(err) ? err : new InternalServerError(createErrorMessage(err)); - // Potentially render the error in the view - return { type: 'error', error, templateFiles: this.viewTemplates }; - } - default: - throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`); - } + public async handle(input: InteractionHandlerInput): Promise { + return this.source.handle(input); } } diff --git a/src/identity/interaction/routing/InteractionRoute.ts b/src/identity/interaction/routing/InteractionRoute.ts index 5287ce9b6..3b0caccff 100644 --- a/src/identity/interaction/routing/InteractionRoute.ts +++ b/src/identity/interaction/routing/InteractionRoute.ts @@ -1,33 +1,9 @@ -import type { Operation } from '../../../http/Operation'; -import type { Interaction, InteractionHandlerResult } from '../InteractionHandler'; - -export type TemplatedInteractionResult = T & { - templateFiles: Record; -}; - /** - * Handles the routing behaviour for IDP handlers. + * An object with a specific path. */ export interface InteractionRoute { /** - * Returns the control fields that should be added to response objects. - * Keys are control names, values are relative URL paths. + * @returns The absolute path of this route. */ - getControls: () => Record; - - /** - * If this route supports the given path. - * @param path - Relative URL path. - * @param prompt - Session prompt if there is one. - */ - supportsPath: (path: string, prompt?: string) => boolean; - - /** - * Handles the given operation. - * @param operation - Operation to handle. - * @param oidcInteraction - Interaction if there is one. - * - * @returns InteractionHandlerResult appended with relevant template files. - */ - handleOperation: (operation: Operation, oidcInteraction?: Interaction) => Promise; + getPath: () => string; } diff --git a/src/identity/interaction/routing/RelativeInteractionRoute.ts b/src/identity/interaction/routing/RelativeInteractionRoute.ts new file mode 100644 index 000000000..6443b5ec3 --- /dev/null +++ b/src/identity/interaction/routing/RelativeInteractionRoute.ts @@ -0,0 +1,18 @@ +import { joinUrl } from '../../../util/PathUtil'; +import type { InteractionHandler } from '../InteractionHandler'; +import { BasicInteractionRoute } from './BasicInteractionRoute'; +import type { InteractionRoute } from './InteractionRoute'; + +/** + * A route that is relative to another route. + * The relative path will be joined to the input base, + * which can either be an absolute URL or an InteractionRoute of which the path will be used. + * The source handler will be called for all operation requests + */ +export class RelativeInteractionRoute extends BasicInteractionRoute { + public constructor(base: InteractionRoute | string, relativePath: string, source?: InteractionHandler) { + const url = typeof base === 'string' ? base : base.getPath(); + const path = joinUrl(url, relativePath); + super(path, source); + } +} diff --git a/src/index.ts b/src/index.ts index f3ed16de2..f965ff15d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -147,6 +147,7 @@ export * from './identity/interaction/email-password/EmailPasswordUtil'; // Identity/Interaction/Routing export * from './identity/interaction/routing/BasicInteractionRoute'; export * from './identity/interaction/routing/InteractionRoute'; +export * from './identity/interaction/routing/RelativeInteractionRoute'; // Identity/Interaction/Util export * from './identity/interaction/util/BaseEmailSender'; @@ -155,9 +156,13 @@ export * from './identity/interaction/util/EmailSender'; export * from './identity/interaction/util/InteractionCompleter'; // Identity/Interaction +export * from './identity/interaction/BaseInteractionHandler'; export * from './identity/interaction/CompletingInteractionHandler'; +export * from './identity/interaction/ExistingLoginHandler'; +export * from './identity/interaction/ControlHandler'; +export * from './identity/interaction/HtmlViewHandler'; export * from './identity/interaction/InteractionHandler'; -export * from './identity/interaction/SessionHttpHandler'; +export * from './identity/interaction/PromptHandler'; // Identity/Ownership export * from './identity/ownership/NoCheckOwnershipValidator'; diff --git a/templates/identity/email-password/confirm.html.ejs b/templates/identity/email-password/consent.html.ejs similarity index 100% rename from templates/identity/email-password/confirm.html.ejs rename to templates/identity/email-password/consent.html.ejs diff --git a/test/unit/identity/ControlHandler.test.ts b/test/unit/identity/ControlHandler.test.ts new file mode 100644 index 000000000..cafa667f2 --- /dev/null +++ b/test/unit/identity/ControlHandler.test.ts @@ -0,0 +1,52 @@ +import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation'; +import { ControlHandler } from '../../../src/identity/interaction/ControlHandler'; +import type { InteractionHandler, InteractionHandlerInput } from '../../../src/identity/interaction/InteractionHandler'; +import type { InteractionRoute } from '../../../src/identity/interaction/routing/InteractionRoute'; +import { APPLICATION_JSON } from '../../../src/util/ContentTypes'; +import { InternalServerError } from '../../../src/util/errors/InternalServerError'; +import { readJsonStream } from '../../../src/util/StreamUtil'; + +describe('A ControlHandler', (): void => { + const input: InteractionHandlerInput = {} as any; + let controls: Record>; + let source: jest.Mocked; + let handler: ControlHandler; + + beforeEach(async(): Promise => { + controls = { + login: { getPath: jest.fn().mockReturnValue('http://example.com/login/') } as any, + register: { getPath: jest.fn().mockReturnValue('http://example.com/register/') } as any, + }; + + source = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue(new BasicRepresentation(JSON.stringify({ data: 'data' }), APPLICATION_JSON)), + } as any; + + handler = new ControlHandler(source, controls); + }); + + it('can handle any input its source can handle.', async(): Promise => { + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + + source.canHandle.mockRejectedValueOnce(new Error('bad data')); + await expect(handler.canHandle(input)).rejects.toThrow('bad data'); + }); + + it('errors in case its source does not return JSON.', async(): Promise => { + source.handle.mockResolvedValueOnce(new BasicRepresentation()); + await expect(handler.handle(input)).rejects.toThrow(InternalServerError); + }); + + it('adds controls to the source response.', async(): Promise => { + const result = await handler.handle(input); + await expect(readJsonStream(result.data)).resolves.toEqual({ + data: 'data', + apiVersion: '0.3', + controls: { + login: 'http://example.com/login/', + register: 'http://example.com/register/', + }, + }); + }); +}); diff --git a/test/unit/identity/IdentityProviderHttpHandler.test.ts b/test/unit/identity/IdentityProviderHttpHandler.test.ts index 6e0deb201..9b5b2fdad 100644 --- a/test/unit/identity/IdentityProviderHttpHandler.test.ts +++ b/test/unit/identity/IdentityProviderHttpHandler.test.ts @@ -1,14 +1,12 @@ import type { Provider } from 'oidc-provider'; import type { Operation } from '../../../src/http/Operation'; -import type { ErrorHandler, ErrorHandlerArgs } from '../../../src/http/output/error/ErrorHandler'; -import type { ResponseDescription } from '../../../src/http/output/response/ResponseDescription'; import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../src/http/representation/Representation'; import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata'; import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory'; import type { IdentityProviderHttpHandlerArgs } from '../../../src/identity/IdentityProviderHttpHandler'; import { IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler'; -import type { InteractionRoute } from '../../../src/identity/interaction/routing/InteractionRoute'; +import type { Interaction, InteractionHandler } from '../../../src/identity/interaction/InteractionHandler'; import type { HttpRequest } from '../../../src/server/HttpRequest'; import type { HttpResponse } from '../../../src/server/HttpResponse'; import { getBestPreference } from '../../../src/storage/conversion/ConversionUtil'; @@ -16,25 +14,20 @@ import type { RepresentationConverter, RepresentationConverterArgs, } from '../../../src/storage/conversion/RepresentationConverter'; -import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; -import { joinUrl } from '../../../src/util/PathUtil'; -import { guardedStreamFrom, readableToString } from '../../../src/util/StreamUtil'; -import { CONTENT_TYPE, SOLID_META } from '../../../src/util/Vocabularies'; +import { APPLICATION_JSON, APPLICATION_X_WWW_FORM_URLENCODED } from '../../../src/util/ContentTypes'; +import { CONTENT_TYPE } from '../../../src/util/Vocabularies'; describe('An IdentityProviderHttpHandler', (): void => { - const apiVersion = '0.2'; - const baseUrl = 'http://test.com/'; - const idpPath = '/idp'; const request: HttpRequest = {} as any; const response: HttpResponse = {} as any; + const oidcInteraction: Interaction = {} as any; let operation: Operation; + let representation: Representation; let providerFactory: jest.Mocked; - let routes: Record<'response' | 'complete' | 'error', jest.Mocked>; - let controls: Record; let converter: jest.Mocked; - let errorHandler: jest.Mocked; let provider: jest.Mocked; - let handler: IdentityProviderHttpHandler; + let handler: jest.Mocked; + let idpHandler: IdentityProviderHttpHandler; beforeEach(async(): Promise => { operation = { @@ -45,44 +38,13 @@ describe('An IdentityProviderHttpHandler', (): void => { }; provider = { - interactionDetails: jest.fn(), + interactionDetails: jest.fn().mockReturnValue(oidcInteraction), } as any; providerFactory = { getProvider: jest.fn().mockResolvedValue(provider), }; - routes = { - response: { - getControls: jest.fn().mockReturnValue({ response: '/routeResponse' }), - supportsPath: jest.fn((path: string): boolean => /^\/routeResponse$/u.test(path)), - handleOperation: jest.fn().mockResolvedValue({ - type: 'response', - details: { key: 'val' }, - templateFiles: { 'text/html': '/response' }, - }), - }, - complete: { - getControls: jest.fn().mockReturnValue({}), - supportsPath: jest.fn((path: string): boolean => /^\/routeComplete$/u.test(path)), - handleOperation: jest.fn().mockResolvedValue({ - type: 'complete', - details: { webId: 'webId' }, - templateFiles: {}, - }), - }, - error: { - getControls: jest.fn().mockReturnValue({}), - supportsPath: jest.fn((path: string): boolean => /^\/routeError$/u.test(path)), - handleOperation: jest.fn().mockResolvedValue({ - type: 'error', - error: new Error('test error'), - templateFiles: { 'text/html': '/response' }, - }), - }, - }; - controls = { response: 'http://test.com/idp/routeResponse' }; - converter = { handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => { // Just find the best match; @@ -92,91 +54,50 @@ describe('An IdentityProviderHttpHandler', (): void => { }), } as any; - errorHandler = { handleSafe: jest.fn(({ error }: ErrorHandlerArgs): ResponseDescription => ({ - statusCode: 400, - data: guardedStreamFrom(`{ "name": "${error.name}", "message": "${error.message}" }`), - })) } as any; + representation = new BasicRepresentation(); + handler = { + handleSafe: jest.fn().mockResolvedValue(representation), + } as any; const args: IdentityProviderHttpHandlerArgs = { - baseUrl, - idpPath, providerFactory, - interactionRoutes: Object.values(routes), converter, - errorHandler, + handler, }; - handler = new IdentityProviderHttpHandler(args); + idpHandler = new IdentityProviderHttpHandler(args); }); - it('throws a 404 if there is no matching route.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, 'invalid'); - await expect(handler.handle({ request, response, operation })).rejects.toThrow(NotFoundHttpError); - }); - - it('creates Representations for InteractionResponseResults.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, '/idp/routeResponse'); - operation.method = 'POST'; - operation.body = new BasicRepresentation('value', 'text/plain'); - const result = (await handler.handle({ request, response, operation }))!; - expect(result).toBeDefined(); - expect(routes.response.handleOperation).toHaveBeenCalledTimes(1); - expect(routes.response.handleOperation).toHaveBeenLastCalledWith(operation, undefined); - expect(operation.body?.metadata.contentType).toBe('application/json'); - - expect(JSON.parse(await readableToString(result.data!))) - .toEqual({ apiVersion, key: 'val', authenticating: false, controls }); + it('returns the handler result as 200 response.', async(): Promise => { + const result = await idpHandler.handle({ operation, request, response }); expect(result.statusCode).toBe(200); - expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe('/response'); + expect(result.data).toBe(representation.data); + expect(result.metadata).toBe(representation.metadata); + expect(handler.handleSafe).toHaveBeenCalledTimes(1); + expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); }); - it('creates Representations for InteractionErrorResults.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, '/idp/routeError'); - operation.method = 'POST'; - operation.preferences = { type: { 'text/html': 1 }}; - - const result = (await handler.handle({ request, response, operation }))!; - expect(result).toBeDefined(); - expect(routes.error.handleOperation).toHaveBeenCalledTimes(1); - expect(routes.error.handleOperation).toHaveBeenLastCalledWith(operation, undefined); - - expect(JSON.parse(await readableToString(result.data!))) - .toEqual({ apiVersion, name: 'Error', message: 'test error', authenticating: false, controls }); - expect(result.statusCode).toBe(400); - expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe('/response'); + it('passes no interaction if the provider call failed.', async(): Promise => { + provider.interactionDetails.mockRejectedValueOnce(new Error('no interaction')); + const result = await idpHandler.handle({ operation, request, response }); + expect(result.statusCode).toBe(200); + expect(result.data).toBe(representation.data); + expect(result.metadata).toBe(representation.metadata); + expect(handler.handleSafe).toHaveBeenCalledTimes(1); + expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation }); }); - it('adds a prefilled field in case error requests had a body.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, '/idp/routeError'); - operation.method = 'POST'; - operation.preferences = { type: { 'text/html': 1 }}; - operation.body = new BasicRepresentation('{ "key": "val" }', 'application/json'); - - const result = (await handler.handle({ request, response, operation }))!; - expect(result).toBeDefined(); - expect(routes.error.handleOperation).toHaveBeenCalledTimes(1); - expect(routes.error.handleOperation).toHaveBeenLastCalledWith(operation, undefined); - expect(operation.body?.metadata.contentType).toBe('application/json'); - - expect(JSON.parse(await readableToString(result.data!))).toEqual( - { apiVersion, name: 'Error', message: 'test error', authenticating: false, controls, prefilled: { key: 'val' }}, + it('converts input bodies to JSON.', async(): Promise => { + operation.body.metadata.contentType = APPLICATION_X_WWW_FORM_URLENCODED; + const result = await idpHandler.handle({ operation, request, response }); + expect(result.statusCode).toBe(200); + expect(result.data).toBe(representation.data); + expect(result.metadata).toBe(representation.metadata); + expect(handler.handleSafe).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { body, ...partialOperation } = operation; + expect(handler.handleSafe).toHaveBeenLastCalledWith( + { operation: expect.objectContaining(partialOperation), oidcInteraction }, ); - expect(result.statusCode).toBe(400); - expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe('/response'); - }); - - it('indicates to the templates if the request is part of an auth flow.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, '/idp/routeResponse'); - operation.method = 'POST'; - const oidcInteraction = { session: { accountId: 'account' }, prompt: {}} as any; - provider.interactionDetails.mockResolvedValueOnce(oidcInteraction); - routes.response.handleOperation - .mockResolvedValueOnce({ type: 'response', templateFiles: { 'text/html': '/response' }}); - - const result = (await handler.handle({ request, response, operation }))!; - expect(result).toBeDefined(); - expect(JSON.parse(await readableToString(result.data!))).toEqual({ apiVersion, authenticating: true, controls }); + expect(handler.handleSafe.mock.calls[0][0].operation.body.metadata.contentType).toBe(APPLICATION_JSON); }); }); diff --git a/test/unit/identity/configuration/IdentityProviderFactory.test.ts b/test/unit/identity/configuration/IdentityProviderFactory.test.ts index e506b7272..bffb4787e 100644 --- a/test/unit/identity/configuration/IdentityProviderFactory.test.ts +++ b/test/unit/identity/configuration/IdentityProviderFactory.test.ts @@ -1,10 +1,13 @@ -import type { Configuration } from 'oidc-provider'; +import type { Configuration, KoaContextWithOIDC } from 'oidc-provider'; import type { ErrorHandler } from '../../../../src/http/output/error/ErrorHandler'; import type { ResponseWriter } from '../../../../src/http/output/ResponseWriter'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import { IdentityProviderFactory } from '../../../../src/identity/configuration/IdentityProviderFactory'; +import type { Interaction, InteractionHandler } from '../../../../src/identity/interaction/InteractionHandler'; import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory'; import type { HttpResponse } from '../../../../src/server/HttpResponse'; import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; +import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; /* eslint-disable @typescript-eslint/naming-convention */ jest.mock('oidc-provider', (): any => ({ @@ -28,10 +31,13 @@ const routes = { describe('An IdentityProviderFactory', (): void => { let baseConfig: Configuration; - const baseUrl = 'http://test.com/foo/'; + const baseUrl = 'http://example.com/foo/'; const oidcPath = '/oidc'; - const idpPath = '/idp'; - const webId = 'http://alice.test.com/card#me'; + const webId = 'http://alice.example.com/card#me'; + const redirectUrl = 'http://example.com/login/'; + const oidcInteraction: Interaction = {} as any; + let ctx: KoaContextWithOIDC; + let interactionHandler: jest.Mocked; let adapterFactory: jest.Mocked; let storage: jest.Mocked>; let errorHandler: jest.Mocked; @@ -41,6 +47,17 @@ describe('An IdentityProviderFactory', (): void => { beforeEach(async(): Promise => { baseConfig = { claims: { webid: [ 'webid', 'client_webid' ]}}; + ctx = { + method: 'GET', + request: { + href: 'http://example.com/idp/', + }, + } as any; + + interactionHandler = { + handleSafe: jest.fn().mockRejectedValue(new FoundHttpError(redirectUrl)), + } as any; + adapterFactory = { createStorageAdapter: jest.fn().mockReturnValue('adapter!'), }; @@ -61,25 +78,13 @@ describe('An IdentityProviderFactory', (): void => { adapterFactory, baseUrl, oidcPath, - idpPath, + interactionHandler, storage, errorHandler, responseWriter, }); }); - it('errors if the idpPath parameter does not start with a slash.', async(): Promise => { - expect((): any => new IdentityProviderFactory(baseConfig, { - adapterFactory, - baseUrl, - oidcPath, - idpPath: 'idp', - storage, - errorHandler, - responseWriter, - })).toThrow('idpPath needs to start with a /'); - }); - it('creates a correct configuration.', async(): Promise => { // This is the output of our mock function const provider = await factory.getProvider() as any; @@ -98,7 +103,7 @@ describe('An IdentityProviderFactory', (): void => { expect(config.jwks).toEqual({ keys: [ expect.objectContaining({ kty: 'RSA' }) ]}); expect(config.routes).toEqual(routes); - expect((config.interactions?.url as any)()).toBe('/idp/'); + await expect((config.interactions?.url as any)(ctx, oidcInteraction)).resolves.toBe(redirectUrl); expect((config.audiences as any)(null, null, {}, 'access_token')).toBe('solid'); expect((config.audiences as any)(null, null, { clientId: 'clientId' }, 'client_credentials')).toBe('clientId'); @@ -123,6 +128,17 @@ describe('An IdentityProviderFactory', (): void => { expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }}); }); + it('errors if there is no valid interaction redirect.', async(): Promise => { + interactionHandler.handleSafe.mockRejectedValueOnce(new Error('bad data')); + const provider = await factory.getProvider() as any; + const { config } = provider as { config: Configuration }; + await expect((config.interactions?.url as any)(ctx, oidcInteraction)).rejects.toThrow('bad data'); + + interactionHandler.handleSafe.mockResolvedValueOnce(new BasicRepresentation()); + await expect((config.interactions?.url as any)(ctx, oidcInteraction)) + .rejects.toThrow('Could not correctly redirect for the given interaction.'); + }); + it('copies a field from the input config if values need to be added to it.', async(): Promise => { baseConfig.cookies = { long: { signed: true }, @@ -131,7 +147,7 @@ describe('An IdentityProviderFactory', (): void => { adapterFactory, baseUrl, oidcPath, - idpPath, + interactionHandler, storage, errorHandler, responseWriter, @@ -153,7 +169,7 @@ describe('An IdentityProviderFactory', (): void => { adapterFactory, baseUrl, oidcPath, - idpPath, + interactionHandler, storage, errorHandler, responseWriter, diff --git a/test/unit/identity/interaction/BaseInteractionHandler.test.ts b/test/unit/identity/interaction/BaseInteractionHandler.test.ts new file mode 100644 index 000000000..4736ee2d0 --- /dev/null +++ b/test/unit/identity/interaction/BaseInteractionHandler.test.ts @@ -0,0 +1,70 @@ +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { Representation } from '../../../../src/http/representation/Representation'; +import { BaseInteractionHandler } from '../../../../src/identity/interaction/BaseInteractionHandler'; +import type { InteractionHandlerInput } from '../../../../src/identity/interaction/InteractionHandler'; +import { APPLICATION_JSON } from '../../../../src/util/ContentTypes'; +import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError'; +import { readJsonStream } from '../../../../src/util/StreamUtil'; + +class DummyBaseInteractionHandler extends BaseInteractionHandler { + public constructor() { + super({ view: 'view' }); + } + + public async handlePost(input: InteractionHandlerInput): Promise { + return new BasicRepresentation(JSON.stringify({ data: 'data' }), input.operation.target, APPLICATION_JSON); + } +} + +describe('A BaseInteractionHandler', (): void => { + const handler = new DummyBaseInteractionHandler(); + + it('can only handle GET and POST requests.', async(): Promise => { + const operation: Operation = { + method: 'DELETE', + target: { path: 'http://example.com/foo' }, + body: new BasicRepresentation(), + preferences: {}, + }; + await expect(handler.canHandle({ operation })).rejects.toThrow(MethodNotAllowedHttpError); + + operation.method = 'GET'; + await expect(handler.canHandle({ operation })).resolves.toBeUndefined(); + + operation.method = 'POST'; + await expect(handler.canHandle({ operation })).resolves.toBeUndefined(); + }); + + it('returns the view on GET requests.', async(): Promise => { + const operation: Operation = { + method: 'GET', + target: { path: 'http://example.com/foo' }, + body: new BasicRepresentation(), + preferences: {}, + }; + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ view: 'view' }); + }); + + it('calls the handlePost function on POST requests.', async(): Promise => { + const operation: Operation = { + method: 'POST', + target: { path: 'http://example.com/foo' }, + body: new BasicRepresentation(), + preferences: {}, + }; + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ data: 'data' }); + }); + + it('rejects other methods.', async(): Promise => { + const operation: Operation = { + method: 'DELETE', + target: { path: 'http://example.com/foo' }, + body: new BasicRepresentation(), + preferences: {}, + }; + await expect(handler.handle({ operation })).rejects.toThrow(MethodNotAllowedHttpError); + }); +}); diff --git a/test/unit/identity/interaction/CompletingInteractionHandler.test.ts b/test/unit/identity/interaction/CompletingInteractionHandler.test.ts index 4fbc4cb69..34a221d4f 100644 --- a/test/unit/identity/interaction/CompletingInteractionHandler.test.ts +++ b/test/unit/identity/interaction/CompletingInteractionHandler.test.ts @@ -1,7 +1,10 @@ import type { Operation } from '../../../../src/http/Operation'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import { CompletingInteractionHandler } from '../../../../src/identity/interaction/CompletingInteractionHandler'; -import type { Interaction, InteractionHandlerInput } from '../../../../src/identity/interaction/InteractionHandler'; +import type { + Interaction, + InteractionHandlerInput, +} from '../../../../src/identity/interaction/InteractionHandler'; import type { InteractionCompleter, InteractionCompleterInput, @@ -11,7 +14,7 @@ import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplemen const webId = 'http://alice.test.com/card#me'; class DummyCompletingInteractionHandler extends CompletingInteractionHandler { public constructor(interactionCompleter: InteractionCompleter) { - super(interactionCompleter); + super({}, interactionCompleter); } public async getCompletionParameters(input: Required): Promise { @@ -28,7 +31,10 @@ describe('A CompletingInteractionHandler', (): void => { beforeEach(async(): Promise => { const representation = new BasicRepresentation('', 'application/json'); - operation = { body: representation } as any; + operation = { + method: 'POST', + body: representation, + } as any; interactionCompleter = { handleSafe: jest.fn().mockResolvedValue(location), @@ -39,10 +45,15 @@ describe('A CompletingInteractionHandler', (): void => { it('calls the parent JSON canHandle check.', async(): Promise => { operation.body.metadata.contentType = 'application/x-www-form-urlencoded'; - await expect(handler.canHandle({ operation } as any)).rejects.toThrow(NotImplementedHttpError); + await expect(handler.canHandle({ operation, oidcInteraction } as any)).rejects.toThrow(NotImplementedHttpError); }); - it('errors if no OidcInteraction is defined.', async(): Promise => { + it('can handle GET requests without interaction.', async(): Promise => { + operation.method = 'GET'; + await expect(handler.canHandle({ operation } as any)).resolves.toBeUndefined(); + }); + + it('errors if no OidcInteraction is defined on POST requests.', async(): Promise => { const error = expect.objectContaining({ statusCode: 400, message: 'This action can only be performed as part of an OIDC authentication flow.', diff --git a/test/unit/identity/interaction/SessionHttpHandler.test.ts b/test/unit/identity/interaction/ExistingLoginHandler.test.ts similarity index 50% rename from test/unit/identity/interaction/SessionHttpHandler.test.ts rename to test/unit/identity/interaction/ExistingLoginHandler.test.ts index ff6206e2b..c649e0a23 100644 --- a/test/unit/identity/interaction/SessionHttpHandler.test.ts +++ b/test/unit/identity/interaction/ExistingLoginHandler.test.ts @@ -1,27 +1,17 @@ -import type { InteractionHandlerInput, Interaction } from '../../../../src/identity/interaction/InteractionHandler'; -import { SessionHttpHandler } from '../../../../src/identity/interaction/SessionHttpHandler'; +import { ExistingLoginHandler } from '../../../../src/identity/interaction/ExistingLoginHandler'; +import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler'; import type { InteractionCompleter, - InteractionCompleterInput, } from '../../../../src/identity/interaction/util/InteractionCompleter'; +import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { createPostJsonOperation } from './email-password/handler/Util'; -class PublicSessionHttpHandler extends SessionHttpHandler { - public constructor(interactionCompleter: InteractionCompleter) { - super(interactionCompleter); - } - - public async getCompletionParameters(input: Required): Promise { - return super.getCompletionParameters(input); - } -} - -describe('A SessionHttpHandler', (): void => { +describe('An ExistingLoginHandler', (): void => { const webId = 'http://test.com/id#me'; let oidcInteraction: Interaction; let interactionCompleter: jest.Mocked; - let handler: PublicSessionHttpHandler; + let handler: ExistingLoginHandler; beforeEach(async(): Promise => { oidcInteraction = { session: { accountId: webId }} as any; @@ -30,18 +20,19 @@ describe('A SessionHttpHandler', (): void => { handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'), } as any; - handler = new PublicSessionHttpHandler(interactionCompleter); + handler = new ExistingLoginHandler(interactionCompleter); }); it('requires an oidcInteraction with a defined session.', async(): Promise => { oidcInteraction.session = undefined; - await expect(handler.getCompletionParameters({ operation: {} as any, oidcInteraction })) + await expect(handler.handle({ operation: createPostJsonOperation({}), oidcInteraction })) .rejects.toThrow(NotImplementedHttpError); }); it('returns the correct completion parameters.', async(): Promise => { const operation = createPostJsonOperation({ remember: true }); - await expect(handler.getCompletionParameters({ operation, oidcInteraction })) - .resolves.toEqual({ oidcInteraction, webId, shouldRemember: true }); + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); + expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); + expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ oidcInteraction, webId, shouldRemember: true }); }); }); diff --git a/test/unit/identity/interaction/HtmlViewHandler.test.ts b/test/unit/identity/interaction/HtmlViewHandler.test.ts new file mode 100644 index 000000000..5836f8c7f --- /dev/null +++ b/test/unit/identity/interaction/HtmlViewHandler.test.ts @@ -0,0 +1,68 @@ +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import { HtmlViewHandler } from '../../../../src/identity/interaction/HtmlViewHandler'; +import type { InteractionRoute } from '../../../../src/identity/interaction/routing/InteractionRoute'; +import { TEXT_HTML } from '../../../../src/util/ContentTypes'; +import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError'; +import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import { readableToString } from '../../../../src/util/StreamUtil'; +import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; + +describe('An HtmlViewHandler', (): void => { + let operation: Operation; + let templates: Record>; + let templateEngine: TemplateEngine; + let handler: HtmlViewHandler; + + beforeEach(async(): Promise => { + operation = { + method: 'GET', + target: { path: 'http://example.com/idp/login/' }, + preferences: { type: { 'text/html': 1 }}, + body: new BasicRepresentation(), + }; + + templates = { + '/templates/login.html.ejs': { getPath: jest.fn().mockReturnValue('http://example.com/idp/login/') } as any, + '/templates/register.html.ejs': { getPath: jest.fn().mockReturnValue('http://example.com/idp/register/') } as any, + }; + + templateEngine = { + render: jest.fn().mockReturnValue(Promise.resolve('')), + }; + + handler = new HtmlViewHandler(templateEngine, templates); + }); + + it('rejects non-GET requests.', async(): Promise => { + operation.method = 'POST'; + await expect(handler.canHandle({ operation })).rejects.toThrow(MethodNotAllowedHttpError); + }); + + it('rejects unsupported paths.', async(): Promise => { + operation.target.path = 'http://example.com/idp/otherPath/'; + await expect(handler.canHandle({ operation })).rejects.toThrow(NotFoundHttpError); + }); + + it('rejects requests that do not prefer HTML to JSON.', async(): Promise => { + operation.preferences = { type: { '*/*': 1 }}; + await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError); + + operation.preferences = { type: { 'application/json': 1, 'text/html': 1 }}; + await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError); + + operation.preferences = { type: { 'application/json': 1, 'text/html': 0.8 }}; + await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError); + }); + + it('can handle matching requests.', async(): Promise => { + await expect(handler.canHandle({ operation })).resolves.toBeUndefined(); + }); + + it('returns the resolved template.', async(): Promise => { + const result = await handler.handle({ operation }); + expect(result.metadata.contentType).toBe(TEXT_HTML); + await expect(readableToString(result.data)).resolves.toBe(''); + }); +}); diff --git a/test/unit/identity/interaction/InteractionHandler.test.ts b/test/unit/identity/interaction/InteractionHandler.test.ts index f74cee567..167fef7aa 100644 --- a/test/unit/identity/interaction/InteractionHandler.test.ts +++ b/test/unit/identity/interaction/InteractionHandler.test.ts @@ -1,22 +1,20 @@ import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; -import type { - InteractionResponseResult, -} from '../../../../src/identity/interaction/InteractionHandler'; +import type { Representation } from '../../../../src/http/representation/Representation'; import { InteractionHandler, } from '../../../../src/identity/interaction/InteractionHandler'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; class SimpleInteractionHandler extends InteractionHandler { - public async handle(): Promise { - return { type: 'response' }; + public async handle(): Promise { + return new BasicRepresentation(); } } describe('An InteractionHandler', (): void => { const handler = new SimpleInteractionHandler(); - it('only supports JSON data.', async(): Promise => { + it('only supports JSON data or empty bodies.', async(): Promise => { let representation = new BasicRepresentation('{}', 'application/json'); await expect(handler.canHandle({ operation: { body: representation }} as any)).resolves.toBeUndefined(); @@ -24,6 +22,7 @@ describe('An InteractionHandler', (): void => { await expect(handler.canHandle({ operation: { body: representation }} as any)) .rejects.toThrow(NotImplementedHttpError); - await expect(handler.canHandle({ operation: {}} as any)).rejects.toThrow(NotImplementedHttpError); + representation = new BasicRepresentation(); + await expect(handler.canHandle({ operation: { body: representation }} as any)).resolves.toBeUndefined(); }); }); diff --git a/test/unit/identity/interaction/PromptHandler.test.ts b/test/unit/identity/interaction/PromptHandler.test.ts new file mode 100644 index 000000000..05a6834ef --- /dev/null +++ b/test/unit/identity/interaction/PromptHandler.test.ts @@ -0,0 +1,37 @@ +import type { Operation } from '../../../../src/http/Operation'; +import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler'; +import { PromptHandler } from '../../../../src/identity/interaction/PromptHandler'; +import type { InteractionRoute } from '../../../../src/identity/interaction/routing/InteractionRoute'; +import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; + +describe('A PromptHandler', (): void => { + const operation: Operation = { target: { path: 'http://example.com/test/' }} as any; + let oidcInteraction: Interaction; + let promptRoutes: Record>; + let handler: PromptHandler; + + beforeEach(async(): Promise => { + oidcInteraction = { prompt: { name: 'login' }} as any; + promptRoutes = { + login: { getPath: jest.fn().mockReturnValue('http://example.com/idp/login/') } as any, + }; + handler = new PromptHandler(promptRoutes); + }); + + it('errors if there is no interaction.', async(): Promise => { + await expect(handler.handle({ operation })).rejects.toThrow(BadRequestHttpError); + }); + + it('errors if the prompt is unsupported.', async(): Promise => { + oidcInteraction.prompt.name = 'unsupported'; + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(BadRequestHttpError); + }); + + it('throws a redirect error with the correct location.', async(): Promise => { + const error = expect.objectContaining({ + statusCode: 302, + location: 'http://example.com/idp/login/', + }); + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(error); + }); +}); diff --git a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts index f905e963c..92701c0a3 100644 --- a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts @@ -3,7 +3,9 @@ import { ForgotPasswordHandler, } from '../../../../../../src/identity/interaction/email-password/handler/ForgotPasswordHandler'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; +import type { InteractionRoute } from '../../../../../../src/identity/interaction/routing/InteractionRoute'; import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender'; +import { readJsonStream } from '../../../../../../src/util/StreamUtil'; import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine'; import { createPostJsonOperation } from './Util'; @@ -11,11 +13,10 @@ describe('A ForgotPasswordHandler', (): void => { let operation: Operation; const email = 'test@test.email'; const recordId = '123456'; - const html = `Reset Password`; + const html = `Reset Password`; let accountStore: AccountStore; - const baseUrl = 'http://test.com/base/'; - const idpPath = '/idp'; let templateEngine: TemplateEngine<{ resetLink: string }>; + let resetRoute: jest.Mocked; let emailSender: EmailSender; let handler: ForgotPasswordHandler; @@ -30,16 +31,19 @@ describe('A ForgotPasswordHandler', (): void => { render: jest.fn().mockResolvedValue(html), } as any; + resetRoute = { + getPath: jest.fn().mockReturnValue('http://test.com/base/idp/resetpassword/'), + } as any; + emailSender = { handleSafe: jest.fn(), } as any; handler = new ForgotPasswordHandler({ accountStore, - baseUrl, - idpPath, templateEngine, emailSender, + resetRoute, }); }); @@ -52,14 +56,15 @@ describe('A ForgotPasswordHandler', (): void => { it('does not send a mail if a ForgotPassword record could not be generated.', async(): Promise => { (accountStore.generateForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('error'); - await expect(handler.handle({ operation })).resolves - .toEqual({ type: 'response', details: { email }}); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ email }); expect(emailSender.handleSafe).toHaveBeenCalledTimes(0); }); it('sends a mail if a ForgotPassword record could be generated.', async(): Promise => { - await expect(handler.handle({ operation })).resolves - .toEqual({ type: 'response', details: { email }}); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ email }); + expect(result.metadata.contentType).toBe('application/json'); expect(emailSender.handleSafe).toHaveBeenCalledTimes(1); expect(emailSender.handleSafe).toHaveBeenLastCalledWith({ recipient: email, diff --git a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts index 456c85917..c4a9392e7 100644 --- a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts @@ -5,22 +5,11 @@ import type { InteractionHandlerInput, } from '../../../../../../src/identity/interaction/InteractionHandler'; import type { - InteractionCompleterInput, InteractionCompleter, } from '../../../../../../src/identity/interaction/util/InteractionCompleter'; - +import { FoundHttpError } from '../../../../../../src/util/errors/FoundHttpError'; import { createPostJsonOperation } from './Util'; -class PublicLoginHandler extends LoginHandler { - public constructor(accountStore: AccountStore, interactionCompleter: InteractionCompleter) { - super(accountStore, interactionCompleter); - } - - public async getCompletionParameters(input: Required): Promise { - return super.getCompletionParameters(input); - } -} - describe('A LoginHandler', (): void => { const webId = 'http://alice.test.com/card#me'; const email = 'alice@test.email'; @@ -28,7 +17,7 @@ describe('A LoginHandler', (): void => { let input: Required; let accountStore: jest.Mocked; let interactionCompleter: jest.Mocked; - let handler: PublicLoginHandler; + let handler: LoginHandler; beforeEach(async(): Promise => { input = { oidcInteraction } as any; @@ -42,41 +31,43 @@ describe('A LoginHandler', (): void => { handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'), } as any; - handler = new PublicLoginHandler(accountStore, interactionCompleter); + handler = new LoginHandler(accountStore, interactionCompleter); }); it('errors on invalid emails.', async(): Promise => { input.operation = createPostJsonOperation({}); - await expect(handler.getCompletionParameters(input)).rejects.toThrow('Email required'); + await expect(handler.handle(input)).rejects.toThrow('Email required'); input.operation = createPostJsonOperation({ email: [ 'a', 'b' ]}); - await expect(handler.getCompletionParameters(input)).rejects.toThrow('Email required'); + await expect(handler.handle(input)).rejects.toThrow('Email required'); }); it('errors on invalid passwords.', async(): Promise => { input.operation = createPostJsonOperation({ email }); - await expect(handler.getCompletionParameters(input)).rejects.toThrow('Password required'); + await expect(handler.handle(input)).rejects.toThrow('Password required'); input.operation = createPostJsonOperation({ email, password: [ 'a', 'b' ]}); - await expect(handler.getCompletionParameters(input)).rejects.toThrow('Password required'); + await expect(handler.handle(input)).rejects.toThrow('Password required'); }); it('throws an error if there is a problem.', async(): Promise => { input.operation = createPostJsonOperation({ email, password: 'password!' }); accountStore.authenticate.mockRejectedValueOnce(new Error('auth failed!')); - await expect(handler.getCompletionParameters(input)).rejects.toThrow('auth failed!'); + await expect(handler.handle(input)).rejects.toThrow('auth failed!'); }); it('throws an error if the account does not have the correct settings.', async(): Promise => { input.operation = createPostJsonOperation({ email, password: 'password!' }); accountStore.getSettings.mockResolvedValueOnce({ useIdp: false }); - await expect(handler.getCompletionParameters(input)) + await expect(handler.handle(input)) .rejects.toThrow('This server is not an identity provider for this account.'); }); it('returns the correct completion parameters.', async(): Promise => { input.operation = createPostJsonOperation({ email, password: 'password!' }); - await expect(handler.getCompletionParameters(input)) - .resolves.toEqual({ oidcInteraction, webId, shouldRemember: false }); + await expect(handler.handle(input)).rejects.toThrow(FoundHttpError); + expect(accountStore.authenticate).toHaveBeenCalledTimes(1); expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, 'password!'); + expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); + expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ oidcInteraction, webId, shouldRemember: false }); }); }); diff --git a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts index 1a08ea2cd..52ff1c0e1 100644 --- a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts @@ -5,6 +5,7 @@ import { import type { RegistrationManager, RegistrationParams, RegistrationResponse, } from '../../../../../../src/identity/interaction/email-password/util/RegistrationManager'; +import { readJsonStream } from '../../../../../../src/util/StreamUtil'; import { createPostJsonOperation } from './Util'; describe('A RegistrationHandler', (): void => { @@ -41,10 +42,9 @@ describe('A RegistrationHandler', (): void => { it('converts the stream to json and sends it to the registration manager.', async(): Promise => { const params = { email: 'alice@test.email', password: 'superSecret' }; operation = createPostJsonOperation(params); - await expect(handler.handle({ operation })).resolves.toEqual({ - type: 'response', - details, - }); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual(details); + expect(result.metadata.contentType).toBe('application/json'); expect(registrationManager.validateInput).toHaveBeenCalledTimes(1); expect(registrationManager.validateInput).toHaveBeenLastCalledWith(params, false); diff --git a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts index 73d7c851e..cf589350d 100644 --- a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts @@ -3,6 +3,7 @@ import { ResetPasswordHandler, } from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordHandler'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; +import { readJsonStream } from '../../../../../../src/util/StreamUtil'; import { createPostJsonOperation } from './Util'; describe('A ResetPasswordHandler', (): void => { @@ -46,7 +47,9 @@ describe('A ResetPasswordHandler', (): void => { it('renders a message on success.', async(): Promise => { operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!', recordId }, url); - await expect(handler.handle({ operation })).resolves.toEqual({ type: 'response' }); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({}); + expect(result.metadata.contentType).toBe('application/json'); expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1); expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId); expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1); diff --git a/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts b/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts index 28bda2c1c..7ca15e559 100644 --- a/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts +++ b/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts @@ -1,92 +1,59 @@ +import type { Operation } from '../../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation'; +import type { Representation } from '../../../../../src/http/representation/Representation'; import type { InteractionHandler, } from '../../../../../src/identity/interaction/InteractionHandler'; import { BasicInteractionRoute } from '../../../../../src/identity/interaction/routing/BasicInteractionRoute'; -import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; -import { FoundHttpError } from '../../../../../src/util/errors/FoundHttpError'; -import { InternalServerError } from '../../../../../src/util/errors/InternalServerError'; +import { APPLICATION_JSON } from '../../../../../src/util/ContentTypes'; +import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; +import { createPostJsonOperation } from '../email-password/handler/Util'; describe('A BasicInteractionRoute', (): void => { - const path = '^/route$'; - const viewTemplates = { 'text/html': '/viewTemplate' }; - let handler: jest.Mocked; - const prompt = 'login'; - const responseTemplates = { 'text/html': '/responseTemplate' }; - const controls = { login: '/route' }; - const response = { type: 'response' }; + const path = 'http://example.com/idp/path/'; + let operation: Operation; + let representation: Representation; + let source: jest.Mocked; let route: BasicInteractionRoute; beforeEach(async(): Promise => { - handler = { - handleSafe: jest.fn().mockResolvedValue(response), + operation = createPostJsonOperation({}, 'http://example.com/idp/path/'); + + representation = new BasicRepresentation(JSON.stringify({}), APPLICATION_JSON); + + source = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue(representation), } as any; - route = new BasicInteractionRoute(path, viewTemplates, handler, prompt, responseTemplates, controls); + route = new BasicInteractionRoute(path, source); }); - it('returns its controls.', async(): Promise => { - expect(route.getControls()).toEqual(controls); + it('returns the given path.', async(): Promise => { + expect(route.getPath()).toBe('http://example.com/idp/path/'); }); - it('supports a path if it matches the stored route.', async(): Promise => { - expect(route.supportsPath('/route')).toBe(true); - expect(route.supportsPath('/notRoute')).toBe(false); + it('rejects other paths.', async(): Promise => { + operation = createPostJsonOperation({}, 'http://example.com/idp/otherPath/'); + await expect(route.canHandle({ operation })).rejects.toThrow(NotFoundHttpError); }); - it('supports prompts when targeting the base path.', async(): Promise => { - expect(route.supportsPath('/', prompt)).toBe(true); - expect(route.supportsPath('/notRoute', prompt)).toBe(false); - expect(route.supportsPath('/', 'notPrompt')).toBe(false); + it('rejects input its source cannot handle.', async(): Promise => { + source.canHandle.mockRejectedValueOnce(new Error('bad data')); + await expect(route.canHandle({ operation })).rejects.toThrow('bad data'); }); - it('returns a response result on a GET request.', async(): Promise => { - await expect(route.handleOperation({ method: 'GET' } as any)) - .resolves.toEqual({ type: 'response', templateFiles: viewTemplates }); + it('can handle requests its source can handle.', async(): Promise => { + await expect(route.canHandle({ operation })).resolves.toBeUndefined(); }); - it('returns the result of the InteractionHandler on POST requests.', async(): Promise => { - await expect(route.handleOperation({ method: 'POST' } as any)) - .resolves.toEqual({ ...response, templateFiles: responseTemplates }); - expect(handler.handleSafe).toHaveBeenCalledTimes(1); - expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation: { method: 'POST' }}); + it('lets its source handle requests.', async(): Promise => { + await expect(route.handle({ operation })).resolves.toBe(representation); }); - it('creates an error result in case the InteractionHandler errors.', async(): Promise => { - const error = new Error('bad data'); - handler.handleSafe.mockRejectedValueOnce(error); - await expect(route.handleOperation({ method: 'POST' } as any)) - .resolves.toEqual({ type: 'error', error, templateFiles: viewTemplates }); - }); - - it('re-throws redirect errors.', async(): Promise => { - const error = new FoundHttpError('http://test.com/redirect'); - handler.handleSafe.mockRejectedValueOnce(error); - await expect(route.handleOperation({ method: 'POST' } as any)).rejects.toThrow(error); - }); - - it('creates an internal error in case of non-native errors.', async(): Promise => { - handler.handleSafe.mockRejectedValueOnce('notAnError'); - await expect(route.handleOperation({ method: 'POST' } as any)).resolves.toEqual({ - type: 'error', - error: new InternalServerError('Unknown error: notAnError'), - templateFiles: viewTemplates, - }); - }); - - it('errors for non-supported operations.', async(): Promise => { - const prom = route.handleOperation({ method: 'DELETE', target: { path: '/route' }} as any); - await expect(prom).rejects.toThrow(BadRequestHttpError); - await expect(prom).rejects.toThrow('Unsupported request: DELETE /route'); - expect(handler.handleSafe).toHaveBeenCalledTimes(0); - }); - - it('defaults to empty controls.', async(): Promise => { - route = new BasicInteractionRoute(path, viewTemplates, handler, prompt); - expect(route.getControls()).toEqual({}); - }); - - it('defaults to empty response templates.', async(): Promise => { - route = new BasicInteractionRoute(path, viewTemplates, handler, prompt); - await expect(route.handleOperation({ method: 'POST' } as any)).resolves.toEqual({ ...response, templateFiles: {}}); + it('defaults to an UnsupportedAsyncHandler if no source is provided.', async(): Promise => { + route = new BasicInteractionRoute(path); + await expect(route.canHandle({ operation })).rejects.toThrow('This route has no associated handler.'); + await expect(route.handle({ operation })).rejects.toThrow('This route has no associated handler.'); }); }); diff --git a/test/unit/identity/interaction/routing/RelativeInteractionRoute.test.ts b/test/unit/identity/interaction/routing/RelativeInteractionRoute.test.ts new file mode 100644 index 000000000..9d8bb8ba8 --- /dev/null +++ b/test/unit/identity/interaction/routing/RelativeInteractionRoute.test.ts @@ -0,0 +1,30 @@ +import type { + InteractionHandler, +} from '../../../../../src/identity/interaction/InteractionHandler'; +import type { InteractionRoute } from '../../../../../src/identity/interaction/routing/InteractionRoute'; +import { RelativeInteractionRoute } from '../../../../../src/identity/interaction/routing/RelativeInteractionRoute'; + +describe('A RelativeInteractionRoute', (): void => { + const relativePath = '/relative/'; + let route: jest.Mocked; + let source: jest.Mocked; + let relativeRoute: RelativeInteractionRoute; + + beforeEach(async(): Promise => { + route = { + getPath: jest.fn().mockReturnValue('http://example.com/'), + } as any; + + source = { + canHandle: jest.fn(), + } as any; + }); + + it('returns the joined path.', async(): Promise => { + relativeRoute = new RelativeInteractionRoute(route, relativePath, source); + expect(relativeRoute.getPath()).toBe('http://example.com/relative/'); + + relativeRoute = new RelativeInteractionRoute('http://example.com/', relativePath, source); + expect(relativeRoute.getPath()).toBe('http://example.com/relative/'); + }); +}); From a684b2ead7365b9409d7f2f4cfa6755e8b951958 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Fri, 3 Dec 2021 11:46:37 +0100 Subject: [PATCH 22/39] feat: Update IDP templates to work with new API format --- LICENSE.md | 2 +- config/http/static/default.json | 11 +- .../identity/handler/interaction/routes.json | 46 +++--- .../interaction/routes/existing-login.json | 4 +- .../interaction/routes/forgot-password.json | 4 +- .../handler/interaction/routes/index.json | 16 ++ .../handler/interaction/routes/login.json | 4 +- .../handler/interaction/routes/prompt.json | 5 +- .../interaction/routes/reset-password.json | 4 +- .../handler/interaction/views/controls.json | 8 + .../handler/interaction/views/html.json | 1 + .../handler/provider-factory/identity.json | 2 +- config/identity/registration/enabled.json | 8 + .../registration/route/registration.json | 4 +- .../interaction/FixedInteractionHandler.ts | 26 ++++ src/identity/interaction/HtmlViewHandler.ts | 13 +- .../interaction/LocationInteractionHandler.ts | 40 +++++ src/index.ts | 2 + .../identity/email-password/consent.html.ejs | 10 +- .../forgot-password-response.html.ejs | 13 -- .../email-password/forgot-password.html.ejs | 58 +++++-- .../identity/email-password/login.html.ejs | 80 ++++++---- .../email-password/register-partial.html.ejs | 26 +--- .../register-response-partial.html.ejs | 54 ++++--- .../email-password/register-response.html.ejs | 7 - .../identity/email-password/register.html.ejs | 36 ++++- .../reset-password-response.html.ejs | 2 - .../email-password/reset-password.html.ejs | 54 ++++--- templates/main.html.ejs | 7 +- templates/root/prefilled/index.html | 6 +- templates/scripts/util.js | 142 ++++++++++++++++++ templates/setup/response.html.ejs | 35 ++++- templates/styles/main.css | 7 + .../FixedInteractionHandler.test.ts | 15 ++ .../interaction/HtmlViewHandler.test.ts | 20 ++- .../LocationInteractionHandler.test.ts | 62 ++++++++ 36 files changed, 645 insertions(+), 189 deletions(-) create mode 100644 config/identity/handler/interaction/routes/index.json create mode 100644 src/identity/interaction/FixedInteractionHandler.ts create mode 100644 src/identity/interaction/LocationInteractionHandler.ts delete mode 100644 templates/identity/email-password/forgot-password-response.html.ejs delete mode 100644 templates/identity/email-password/register-response.html.ejs delete mode 100644 templates/identity/email-password/reset-password-response.html.ejs create mode 100644 templates/scripts/util.js create mode 100644 test/unit/identity/interaction/FixedInteractionHandler.test.ts create mode 100644 test/unit/identity/interaction/LocationInteractionHandler.test.ts diff --git a/LICENSE.md b/LICENSE.md index b433cd27a..dfb243b82 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright © 2019–2021 Inrupt Inc. and imec +Copyright © 2019–2022 Inrupt Inc. and imec Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/config/http/static/default.json b/config/http/static/default.json index 7d3bb2c79..9dfe69f57 100644 --- a/config/http/static/default.json +++ b/config/http/static/default.json @@ -12,17 +12,22 @@ "StaticAssetHandler:_assets_value": "@css:templates/images/favicon.ico" }, { - "StaticAssetHandler:_assets_key": "/.well_known/css/styles/", + "StaticAssetHandler:_assets_key": "/.well-known/css/styles/", "StaticAssetHandler:_assets_value": "@css:templates/styles/" }, { - "StaticAssetHandler:_assets_key": "/.well_known/css/fonts/", + "StaticAssetHandler:_assets_key": "/.well-known/css/fonts/", "StaticAssetHandler:_assets_value": "@css:templates/fonts/" }, { - "StaticAssetHandler:_assets_key": "/.well_known/css/images/", + "StaticAssetHandler:_assets_key": "/.well-known/css/images/", "StaticAssetHandler:_assets_value": "@css:templates/images/" + }, + { + "StaticAssetHandler:_assets_key": "/.well-known/css/scripts/", + "StaticAssetHandler:_assets_value": "@css:templates/scripts/" } + ] } ] diff --git a/config/identity/handler/interaction/routes.json b/config/identity/handler/interaction/routes.json index 034571015..d1b92d5a6 100644 --- a/config/identity/handler/interaction/routes.json +++ b/config/identity/handler/interaction/routes.json @@ -3,6 +3,7 @@ "import": [ "files-scs:config/identity/handler/interaction/routes/existing-login.json", "files-scs:config/identity/handler/interaction/routes/forgot-password.json", + "files-scs:config/identity/handler/interaction/routes/index.json", "files-scs:config/identity/handler/interaction/routes/login.json", "files-scs:config/identity/handler/interaction/routes/prompt.json", "files-scs:config/identity/handler/interaction/routes/reset-password.json", @@ -21,26 +22,35 @@ { "comment": "Adds controls and API version to JSON responses.", "@id": "urn:solid-server:auth:password:ControlHandler", - "ControlHandler:_source" : { - "@id": "urn:solid-server:auth:password:RouteInteractionHandler", - "@type": "WaterfallHandler", - "handlers": [ - { - "comment": [ - "This handler is required to prevent Components.js issues with arrays.", - "This might be fixed in the next Components.js release after which this can be removed." - ], - "@type": "UnsupportedAsyncHandler" - }, - { "@id": "urn:solid-server:auth:password:PromptRoute" }, - { "@id": "urn:solid-server:auth:password:LoginRoute" }, - { "@id": "urn:solid-server:auth:password:ExistingLoginRoute" }, - { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" }, - { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" } - ] - } + "ControlHandler:_source" : { "@id": "urn:solid-server:auth:password:LocationInteractionHandler" } } ] + }, + { + "comment": "Converts redirect errors to location JSON responses.", + "@id": "urn:solid-server:auth:password:LocationInteractionHandler", + "@type": "LocationInteractionHandler", + "LocationInteractionHandler:_source" : { "@id": "urn:solid-server:auth:password:RouteInteractionHandler" } + }, + { + "comment": "Handles every interaction based on their route.", + "@id": "urn:solid-server:auth:password:RouteInteractionHandler", + "@type": "WaterfallHandler", + "handlers": [ + { + "comment": [ + "This handler is required to prevent Components.js issues with arrays.", + "This might be fixed in the next Components.js release after which this can be removed." + ], + "@type": "UnsupportedAsyncHandler" + }, + { "@id": "urn:solid-server:auth:password:IndexRoute" }, + { "@id": "urn:solid-server:auth:password:PromptRoute" }, + { "@id": "urn:solid-server:auth:password:LoginRoute" }, + { "@id": "urn:solid-server:auth:password:ExistingLoginRoute" }, + { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" }, + { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" } + ] } ] } diff --git a/config/identity/handler/interaction/routes/existing-login.json b/config/identity/handler/interaction/routes/existing-login.json index cb2baaf53..17ad7da19 100644 --- a/config/identity/handler/interaction/routes/existing-login.json +++ b/config/identity/handler/interaction/routes/existing-login.json @@ -5,8 +5,8 @@ "comment": "Handles the interaction that occurs when a logged in user wants to authenticate with a new app.", "@id": "urn:solid-server:auth:password:ExistingLoginRoute", "@type": "RelativeInteractionRoute", - "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "relativePath": "/idp/consent/", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/consent/", "source": { "@type": "ExistingLoginHandler", "interactionCompleter": { "@type": "BaseInteractionCompleter" } diff --git a/config/identity/handler/interaction/routes/forgot-password.json b/config/identity/handler/interaction/routes/forgot-password.json index 946736770..b3e32da5b 100644 --- a/config/identity/handler/interaction/routes/forgot-password.json +++ b/config/identity/handler/interaction/routes/forgot-password.json @@ -5,8 +5,8 @@ "comment": "Handles the forgot password interaction", "@id": "urn:solid-server:auth:password:ForgotPasswordRoute", "@type": "RelativeInteractionRoute", - "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "relativePath": "/idp/forgotpassword/", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/forgotpassword/", "source": { "@type": "ForgotPasswordHandler", "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, diff --git a/config/identity/handler/interaction/routes/index.json b/config/identity/handler/interaction/routes/index.json new file mode 100644 index 000000000..419edd709 --- /dev/null +++ b/config/identity/handler/interaction/routes/index.json @@ -0,0 +1,16 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Root API entry. Returns an empty body so we can add controls pointing to other interaction routes.", + "@id": "urn:solid-server:auth:password:IndexRoute", + "@type": "RelativeInteractionRoute", + "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "relativePath": "/idp/", + "source": { + "@type": "FixedInteractionHandler", + "response": {} + } + } + ] +} diff --git a/config/identity/handler/interaction/routes/login.json b/config/identity/handler/interaction/routes/login.json index bcd00ac67..294af8e49 100644 --- a/config/identity/handler/interaction/routes/login.json +++ b/config/identity/handler/interaction/routes/login.json @@ -5,8 +5,8 @@ "comment": "Handles the login interaction", "@id": "urn:solid-server:auth:password:LoginRoute", "@type": "RelativeInteractionRoute", - "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "relativePath": "/idp/login/", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/login/", "source": { "@type": "LoginHandler", "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, diff --git a/config/identity/handler/interaction/routes/prompt.json b/config/identity/handler/interaction/routes/prompt.json index 9e77dbfef..0dba93633 100644 --- a/config/identity/handler/interaction/routes/prompt.json +++ b/config/identity/handler/interaction/routes/prompt.json @@ -5,10 +5,11 @@ "comment": "Handles OIDC redirects containing a prompt, such as login or consent.", "@id": "urn:solid-server:auth:password:PromptRoute", "@type": "RelativeInteractionRoute", - "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "relativePath": "/idp/", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/prompt/", "source": { "@type": "PromptHandler", + "@id": "urn:solid-server:auth:password:PromptHandler", "promptRoutes": [ { "PromptHandler:_promptRoutes_key": "login", diff --git a/config/identity/handler/interaction/routes/reset-password.json b/config/identity/handler/interaction/routes/reset-password.json index a64a53c2e..5c1bc3b92 100644 --- a/config/identity/handler/interaction/routes/reset-password.json +++ b/config/identity/handler/interaction/routes/reset-password.json @@ -5,8 +5,8 @@ "comment": "Handles the reset password interaction", "@id": "urn:solid-server:auth:password:ResetPasswordRoute", "@type": "RelativeInteractionRoute", - "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "relativePath": "/idp/resetpassword/", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/resetpassword/", "source": { "@type": "ResetPasswordHandler", "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" } diff --git a/config/identity/handler/interaction/views/controls.json b/config/identity/handler/interaction/views/controls.json index 50f11c451..cef0c83cb 100644 --- a/config/identity/handler/interaction/views/controls.json +++ b/config/identity/handler/interaction/views/controls.json @@ -5,6 +5,14 @@ "@id": "urn:solid-server:auth:password:ControlHandler", "@type": "ControlHandler", "controls": [ + { + "ControlHandler:_controls_key": "index", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:IndexRoute" } + }, + { + "ControlHandler:_controls_key": "prompt", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:PromptRoute" } + }, { "ControlHandler:_controls_key": "login", "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:LoginRoute" } diff --git a/config/identity/handler/interaction/views/html.json b/config/identity/handler/interaction/views/html.json index 1ef478b55..ba6e568c9 100644 --- a/config/identity/handler/interaction/views/html.json +++ b/config/identity/handler/interaction/views/html.json @@ -4,6 +4,7 @@ { "@id": "urn:solid-server:auth:password:HtmlViewHandler", "@type": "HtmlViewHandler", + "index": { "@id": "urn:solid-server:auth:password:IndexRoute" }, "templateEngine": { "comment": "Renders the specific page and embeds it into the main HTML body.", "@type": "ChainedTemplateEngine", diff --git a/config/identity/handler/provider-factory/identity.json b/config/identity/handler/provider-factory/identity.json index cde397c61..7efe13956 100644 --- a/config/identity/handler/provider-factory/identity.json +++ b/config/identity/handler/provider-factory/identity.json @@ -11,7 +11,7 @@ "args_adapterFactory": { "@id": "urn:solid-server:default:IdpAdapterFactory" }, "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "args_oidcPath": "/.oidc", - "args_idpPath": "/idp", + "args_interactionHandler": { "@id": "urn:solid-server:auth:password:PromptHandler" }, "args_storage": { "@id": "urn:solid-server:default:IdpKeyStorage" }, "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" }, "args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }, diff --git a/config/identity/registration/enabled.json b/config/identity/registration/enabled.json index 5bebf8d2b..db6579f17 100644 --- a/config/identity/registration/enabled.json +++ b/config/identity/registration/enabled.json @@ -32,6 +32,14 @@ { "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/register.html.ejs", "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:RegistrationRoute" } + }, + { + "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/reset-password-response.html.ejs", + "HtmlViewHandler:_templates_value": { + "@type": "RelativeInteractionRoute", + "base": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" }, + "relativePath": "/response/" + } } ] } diff --git a/config/identity/registration/route/registration.json b/config/identity/registration/route/registration.json index 37e18edd4..9e2e684c1 100644 --- a/config/identity/registration/route/registration.json +++ b/config/identity/registration/route/registration.json @@ -5,8 +5,8 @@ "comment": "Handles the register interaction", "@id": "urn:solid-server:auth:password:RegistrationRoute", "@type": "RelativeInteractionRoute", - "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "relativePath": "/idp/register/", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/register/", "source": { "@type": "RegistrationHandler", "registrationManager": { diff --git a/src/identity/interaction/FixedInteractionHandler.ts b/src/identity/interaction/FixedInteractionHandler.ts new file mode 100644 index 000000000..c3deecf3c --- /dev/null +++ b/src/identity/interaction/FixedInteractionHandler.ts @@ -0,0 +1,26 @@ +/* eslint-disable tsdoc/syntax */ +// tsdoc/syntax cannot handle `@range` +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import { InteractionHandler } from './InteractionHandler'; + +/** + * An {@link InteractionHandler} that always returns the same JSON response on all requests. + */ +export class FixedInteractionHandler extends InteractionHandler { + private readonly response: string; + + /** + * @param response - @range {json} + */ + public constructor(response: unknown) { + super(); + this.response = JSON.stringify(response); + } + + public async handle({ operation }: InteractionHandlerInput): Promise { + return new BasicRepresentation(this.response, operation.target, APPLICATION_JSON); + } +} diff --git a/src/identity/interaction/HtmlViewHandler.ts b/src/identity/interaction/HtmlViewHandler.ts index a9d304f7b..7861f9fcf 100644 --- a/src/identity/interaction/HtmlViewHandler.ts +++ b/src/identity/interaction/HtmlViewHandler.ts @@ -18,13 +18,19 @@ import type { InteractionRoute } from './routing/InteractionRoute'; * Will only handle GET operations for which there is a matching template if HTML is more preferred than JSON. * Reason for doing it like this instead of a standard content negotiation flow * is because we only want to return the HTML pages on GET requests. * + * + * Templates will receive the parameter `idpIndex` in their context pointing to the root index URL of the IDP API + * and an `authenticating` parameter indicating if this is an active OIDC interaction. */ export class HtmlViewHandler extends InteractionHandler { + private readonly idpIndex: string; private readonly templateEngine: TemplateEngine; private readonly templates: Record; - public constructor(templateEngine: TemplateEngine, templates: Record) { + public constructor(index: InteractionRoute, templateEngine: TemplateEngine, + templates: Record) { super(); + this.idpIndex = index.getPath(); this.templateEngine = templateEngine; this.templates = Object.fromEntries( Object.entries(templates).map(([ template, route ]): [ string, string ] => [ route.getPath(), template ]), @@ -46,9 +52,10 @@ export class HtmlViewHandler extends InteractionHandler { } } - public async handle({ operation }: InteractionHandlerInput): Promise { + public async handle({ operation, oidcInteraction }: InteractionHandlerInput): Promise { const template = this.templates[operation.target.path]; - const result = await this.templateEngine.render({}, { templateFile: template }); + const contents = { idpIndex: this.idpIndex, authenticating: Boolean(oidcInteraction) }; + const result = await this.templateEngine.render(contents, { templateFile: template }); return new BasicRepresentation(result, operation.target, TEXT_HTML); } } diff --git a/src/identity/interaction/LocationInteractionHandler.ts b/src/identity/interaction/LocationInteractionHandler.ts new file mode 100644 index 000000000..253e1f357 --- /dev/null +++ b/src/identity/interaction/LocationInteractionHandler.ts @@ -0,0 +1,40 @@ +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import { RedirectHttpError } from '../../util/errors/RedirectHttpError'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import { InteractionHandler } from './InteractionHandler'; + +/** + * Catches redirect errors from the source and returns a JSON body containing a `location` field instead. + * This allows the API to be used more easily from the browser. + * + * The issue is that if the API actually did a redirect, + * this would make it unusable when using it on HTML pages that need to render errors in case the fetch fails, + * but want to redirect the page in case it succeeds. + * See full overview at https://github.com/solid/community-server/pull/1088. + */ +export class LocationInteractionHandler extends InteractionHandler { + private readonly source: InteractionHandler; + + public constructor(source: InteractionHandler) { + super(); + this.source = source; + } + + public async canHandle(input: InteractionHandlerInput): Promise { + await this.source.canHandle(input); + } + + public async handle(input: InteractionHandlerInput): Promise { + try { + return await this.source.handle(input); + } catch (error: unknown) { + if (RedirectHttpError.isInstance(error)) { + const body = JSON.stringify({ location: error.location }); + return new BasicRepresentation(body, input.operation.target, APPLICATION_JSON); + } + throw error; + } + } +} diff --git a/src/index.ts b/src/index.ts index f965ff15d..4b056a28e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -160,8 +160,10 @@ export * from './identity/interaction/BaseInteractionHandler'; export * from './identity/interaction/CompletingInteractionHandler'; export * from './identity/interaction/ExistingLoginHandler'; export * from './identity/interaction/ControlHandler'; +export * from './identity/interaction/FixedInteractionHandler'; export * from './identity/interaction/HtmlViewHandler'; export * from './identity/interaction/InteractionHandler'; +export * from './identity/interaction/LocationInteractionHandler'; export * from './identity/interaction/PromptHandler'; // Identity/Ownership diff --git a/templates/identity/email-password/consent.html.ejs b/templates/identity/email-password/consent.html.ejs index 01797c1c7..cd3edcbce 100644 --- a/templates/identity/email-password/consent.html.ejs +++ b/templates/identity/email-password/consent.html.ejs @@ -1,9 +1,7 @@

Authorize

You are authorizing an application to access your Pod.

-
- <% if (locals.message) { %> -

<%= message %>

- <% } %> + +

    @@ -15,3 +13,7 @@

    + + diff --git a/templates/identity/email-password/forgot-password-response.html.ejs b/templates/identity/email-password/forgot-password-response.html.ejs deleted file mode 100644 index dddc23bf6..000000000 --- a/templates/identity/email-password/forgot-password-response.html.ejs +++ /dev/null @@ -1,13 +0,0 @@ -

    Email sent

    -
    -

    If your account exists, an email has been sent with a link to reset your password.

    -

    If you do not receive your email in a couple of minutes, check your spam folder or click the link below to send another email.

    - - - -

    Back to Log In

    - -

    - -

    -
    diff --git a/templates/identity/email-password/forgot-password.html.ejs b/templates/identity/email-password/forgot-password.html.ejs index 5ed4412cd..84789e516 100644 --- a/templates/identity/email-password/forgot-password.html.ejs +++ b/templates/identity/email-password/forgot-password.html.ejs @@ -1,19 +1,45 @@ -

    Forgot password

    -
    - <% if (locals.message) { %> -

    <%= message %>

    - <% } %> +
    +

    Forgot password

    + +

    -
    -
      -
    1. - - -
    2. -
    -
    +
    +
      +
    1. + + +
    2. +
    +
    -

    +

    -

    Log in

    - +

    Log in

    + +
    +
    +

    Email sent

    +

    If your account exists, an email has been sent with a link to reset your password.

    +

    If you do not receive your email in a couple of minutes, check your spam folder or try sending another email.

    + + +
    + + diff --git a/templates/identity/email-password/login.html.ejs b/templates/identity/email-password/login.html.ejs index 1d28d70a4..e527daa42 100644 --- a/templates/identity/email-password/login.html.ejs +++ b/templates/identity/email-password/login.html.ejs @@ -1,32 +1,56 @@ -

    Log in

    -
    - <% prefilled = locals.prefilled || {}; %> +
    +

    Log in

    + +

    - <% if (locals.message) { %> -

    <%= message %>

    - <% } %> +
    + Your account +
      +
    1. + + +
    2. +
    3. + + +
    4. +
    5. + +
    6. +
    +
    -
    - Your account -
      -
    1. - - -
    2. -
    3. - - -
    4. -
    5. - -
    6. -
    -
    +

    -

    + + +
    +
    +

    Please log in through an app

    +

    To log in and access documents, you need to use a Solid app.

    +

    This server provides secure storage, but it is not a client app.

    +

    + Choose one of the + Solid apps + to log in and browse Pods. +

    +

    + If you're developing an app yourself, + use a library such as + solid-client-authn-js + to initiate an OIDC authentication flow. +

    +
    - - + + diff --git a/templates/identity/email-password/register-partial.html.ejs b/templates/identity/email-password/register-partial.html.ejs index dfe67bea5..815bb3f08 100644 --- a/templates/identity/email-password/register-partial.html.ejs +++ b/templates/identity/email-password/register-partial.html.ejs @@ -165,32 +165,8 @@ } } - // Checks whether the given element is visible - function isVisible(element) { - return !(elements[element] ?? element).classList.contains('hidden'); - } - - // Sets the visibility of the given element - function setVisibility(element, visible) { - // Show or hide the element - element = elements[element] ?? element; - element.classList[visible ? 'remove' : 'add']('hidden'); - - // Disable children of hidden elements, - // such that the browser does not expect input for them - for (const child of getDescendants(element)) { - if ('disabled' in child) - child.disabled = !visible; - } - } - - // Obtains all children, grandchildren, etc. of the given element - function getDescendants(element) { - return [...element.querySelectorAll("*")]; - } - // Prepare the form when the DOM is ready - window.addEventListener('DOMContentLoaded', (event) => { + addEventListener('DOMContentLoaded', (event) => { synchronizeInputFields(); elements.mainForm.classList.add('loaded'); }); diff --git a/templates/identity/email-password/register-response-partial.html.ejs b/templates/identity/email-password/register-response-partial.html.ejs index 0c141edc9..4a94e76bc 100644 --- a/templates/identity/email-password/register-response-partial.html.ejs +++ b/templates/identity/email-password/register-response-partial.html.ejs @@ -1,38 +1,54 @@ -<% if (createPod) { %> +

    Your new Pod

    - Your new Pod is located at <%= podBaseUrl %>. + Your new Pod is located at .
    You can store your documents and data there.

    -<% } %> +
    -<% if (createWebId) { %> +

    Your new WebID

    - Your new WebID is <%= webId %>. + Your new WebID is .
    You can use this identifier to interact with Solid pods and apps.

    -<% } %> +
    -<% if (register) { %> +

    Your new account

    - Via your email address <%= email %>, - <% if (authenticating) { %> - you can now log in - <% } else { %> - this server lets you log in to Solid apps - <% } %> - with your WebID <%= webId %> + Via your email address , + this server lets you log in to Solid apps + with your WebID

    - <% if (!createWebId) { %> +

    You will need to add the triple - <%= `<${webId}> <${oidcIssuer}>.`%> - to your existing WebID document <%= webId %> + + to your existing WebID document to indicate that you trust this server as a login provider.

    - <% } %> -<% } %> +
    +

    + You can now log in. +

    +
    + + diff --git a/templates/identity/email-password/register-response.html.ejs b/templates/identity/email-password/register-response.html.ejs deleted file mode 100644 index bb0689c8d..000000000 --- a/templates/identity/email-password/register-response.html.ejs +++ /dev/null @@ -1,7 +0,0 @@ -

    You've been signed up

    -

    - Welcome to Solid. - We wish you an exciting experience! -

    - -<%- include('./register-response-partial.html.ejs') %> diff --git a/templates/identity/email-password/register.html.ejs b/templates/identity/email-password/register.html.ejs index f1d209bdc..40944d84b 100644 --- a/templates/identity/email-password/register.html.ejs +++ b/templates/identity/email-password/register.html.ejs @@ -1,11 +1,31 @@ -

    Sign up

    -
    +
    +

    Sign up

    + +

    - <% if (locals.message) { %> -

    Error: <%= message %>

    - <% } %> + <%- include('./register-partial.html.ejs', { allowRoot: false }) %> - <%- include('./register-partial.html.ejs', { allowRoot: false }) %> +

    + +
    +
    +

    You've been signed up

    +

    + Welcome to Solid. + We wish you an exciting experience! +

    -

    - + <%- include('./register-response-partial.html.ejs') %> +
    + + diff --git a/templates/identity/email-password/reset-password-response.html.ejs b/templates/identity/email-password/reset-password-response.html.ejs deleted file mode 100644 index 4c7169c58..000000000 --- a/templates/identity/email-password/reset-password-response.html.ejs +++ /dev/null @@ -1,2 +0,0 @@ -

    Password reset

    -

    Your password was successfully reset.

    diff --git a/templates/identity/email-password/reset-password.html.ejs b/templates/identity/email-password/reset-password.html.ejs index 69e9bbfc6..9f8837250 100644 --- a/templates/identity/email-password/reset-password.html.ejs +++ b/templates/identity/email-password/reset-password.html.ejs @@ -1,28 +1,40 @@ -

    Reset password

    -
    - <% if (locals.message) { %> -

    <%= message %>

    - <% } %> +
    +

    Reset password

    + +

    -
    -
      -
    1. - - -
    2. -
    3. - - -
    4. -
    - -
    +
    +
      +
    1. + + +
    2. +
    3. + + +
    4. +
    + +
    -

    - +

    + +
    +
    +

    Password reset

    +

    Your password was successfully reset.

    +
    diff --git a/templates/main.html.ejs b/templates/main.html.ejs index 0c3dfc1b2..26bd02e3f 100644 --- a/templates/main.html.ejs +++ b/templates/main.html.ejs @@ -4,11 +4,12 @@ <%= extractTitle(htmlBody) %> - + +
    - [Solid logo] + [Solid logo]

    Community Solid Server

    @@ -16,7 +17,7 @@
    diff --git a/templates/root/prefilled/index.html b/templates/root/prefilled/index.html index db867df82..1ac5f7cb7 100644 --- a/templates/root/prefilled/index.html +++ b/templates/root/prefilled/index.html @@ -4,11 +4,11 @@ Community Solid Server - +
    - [Solid logo] + [Solid logo]

    Community Solid Server

    @@ -58,7 +58,7 @@
    diff --git a/templates/scripts/util.js b/templates/scripts/util.js new file mode 100644 index 000000000..f0c8099be --- /dev/null +++ b/templates/scripts/util.js @@ -0,0 +1,142 @@ +/** + * Acquires all data from the given form and POSTs it as JSON to the target URL. + * In case of failure this function will throw an error. + * In case of success a parsed JSON body of the response will be returned, + * unless the body contains a `location` field, + * in that case the page will be redirected to that location. + * + * @param formId - ID of the form. + * @param target - Target URL to POST to. Defaults to the current URL. + * @returns {Promise} - The response JSON. + */ +async function postJsonForm(formId, target = '') { + const form = document.getElementById(formId); + const formData = new FormData(form); + const res = await fetch(target, { + method: 'POST', + credentials: 'include', + headers: { 'accept': 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify(Object.fromEntries(formData)), + }); + if (res.status >= 400) { + const error = await res.json(); + throw new Error(`${error.statusCode} - ${error.name}: ${error.message}`) + } else if (res.status === 200 || res.status === 201) { + const body = await res.json(); + if (body.location) { + location.href = body.location; + } else { + return body; + } + } +} + +/** + * Redirects the page to the given target with the key/value pairs of the JSON body as query parameters. + * Controls will be deleted from the JSON to prevent very large URLs. + * `false` values will be deleted to prevent incorrect serializations to "false". + * @param json - JSON to convert. + * @param target - URL to redirect to. + */ +function redirectJsonResponse(json, target) { + // These would cause the URL to get very large, can be acquired later if needed + delete json.controls; + + // Remove false parameters since these would be converted to "false" strings + for (const [key, val] of Object.entries(json)) { + if (typeof val === 'boolean' && !val) { + delete json[key]; + } + } + + const searchParams = new URLSearchParams(Object.entries(json)); + location.href = `${target}?${searchParams.toString()}`; +} + +/** + * Adds a listener to the given form to catch the form submission and do an API call instead. + * In case of an error, the inner text of the given error block will be updated with the message. + * In case of success the callback function will be called. + * + * @param formId - ID of the form. + * @param errorId - ID of the error block. + * @param apiTarget - Target URL to send the POST request to. Defaults to the current URL. + * @param callback - Callback function that will be called with the response JSON. + */ +async function addPostListener(formId, errorId, apiTarget, callback) { + const form = document.getElementById(formId); + const errorBlock = document.getElementById(errorId); + + form.addEventListener('submit', async(event) => { + event.preventDefault(); + + try { + const json = await postJsonForm(formId, apiTarget); + callback(json); + } catch (error) { + errorBlock.innerText = error.message; + } + }); +} + +/** + * Updates links on a page based on the controls received from the API. + * @param url - API URL that will return the controls + * @param controlMap - Key/value map with keys being element IDs and values being the control field names. + */ +async function addControlLinks(url, controlMap) { + const json = await fetchJson(url); + for (let [ id, control ] of Object.entries(controlMap)) { + updateElement(id, json.controls[control], { href: true }); + } +} + +/** + * Shows or hides the given element. + * @param id - ID of the element. + * @param visible - If it should be visible. + */ +function setVisibility(id, visible) { + const element = document.getElementById(id); + element.classList[visible ? 'remove' : 'add']('hidden'); + // Disable children of hidden elements, + // such that the browser does not expect input for them + for (const child of getDescendants(element)) { + if ('disabled' in child) + child.disabled = !visible; + } +} + +/** + * Obtains all children, grandchildren, etc. of the given element. + * @param element - Element to get all descendants from. + */ +function getDescendants(element) { + return [...element.querySelectorAll("*")]; +} + +/** + * Updates the inner text and href field of an element. + * @param id - ID of the element. + * @param text - Text to put in the field(s). + * @param options - Indicates which fields should be updated. + * Keys should be `innerText` and/or `href`, values should be booleans. + */ +function updateElement(id, text, options) { + const element = document.getElementById(id); + if (options.innerText) { + element.innerText = text; + } + if (options.href) { + element.href = text; + } +} + +/** + * Fetches JSON from the url and converts it to an object. + * @param url - URL to fetch JSON from. + */ +async function fetchJson(url) { + const res = await fetch(url, { headers: { accept: 'application/json' } }); + return res.json(); +} diff --git a/templates/setup/response.html.ejs b/templates/setup/response.html.ejs index c416ef82e..fd0764d55 100644 --- a/templates/setup/response.html.ejs +++ b/templates/setup/response.html.ejs @@ -17,5 +17,38 @@ <% } %> <% if (registration) { %> -<%- include('../identity/email-password/register-response-partial.html.ejs', { authenticating: false }) %> + <% if (createPod) { %> +

    Your new Pod

    +

    + Your new Pod is located at <%= podBaseUrl %>. +
    + You can store your documents and data there. +

    + <% } %> + + <% if (createWebId) { %> +

    Your new WebID

    +

    + Your new WebID is <%= webId %>. +
    + You can use this identifier to interact with Solid pods and apps. +

    + <% } %> + + <% if (register) { %> +

    Your new account

    +

    + Via your email address <%= email %>, + this server lets you log in to Solid apps + with your WebID <%= webId %> +

    + <% if (!createWebId) { %> +

    + You will need to add the triple + <%= `<${webId}> <${oidcIssuer}>.`%> + to your existing WebID document <%= webId %> + to indicate that you trust this server as a login provider. +

    + <% } %> + <% } %> <% } %> diff --git a/templates/styles/main.css b/templates/styles/main.css index 830c1f15c..77d8a1bf2 100644 --- a/templates/styles/main.css +++ b/templates/styles/main.css @@ -233,11 +233,18 @@ form ul.actions > li { margin-right: 1em; } +/* Directly hide hidden elements. */ +.hidden { + display: none; +} + +/* Hide form elements with a sliding animation so users can track more easily what is happening. */ form.loaded * { max-height: 1000px; transition: max-height .2s; } form .hidden { + display: block; max-height: 0; overflow: hidden; } diff --git a/test/unit/identity/interaction/FixedInteractionHandler.test.ts b/test/unit/identity/interaction/FixedInteractionHandler.test.ts new file mode 100644 index 000000000..85c9c0e2b --- /dev/null +++ b/test/unit/identity/interaction/FixedInteractionHandler.test.ts @@ -0,0 +1,15 @@ +import type { Operation } from '../../../../src/http/Operation'; +import { FixedInteractionHandler } from '../../../../src/identity/interaction/FixedInteractionHandler'; +import { readJsonStream } from '../../../../src/util/StreamUtil'; + +describe('A FixedInteractionHandler', (): void => { + const json = { data: 'data' }; + const operation: Operation = { target: { path: 'http://example.com/test/' }} as any; + const handler = new FixedInteractionHandler(json); + + it('returns the given JSON as response.', async(): Promise => { + const response = await handler.handle({ operation }); + await expect(readJsonStream(response.data)).resolves.toEqual(json); + expect(response.metadata.contentType).toBe('application/json'); + }); +}); diff --git a/test/unit/identity/interaction/HtmlViewHandler.test.ts b/test/unit/identity/interaction/HtmlViewHandler.test.ts index 5836f8c7f..504bdaca8 100644 --- a/test/unit/identity/interaction/HtmlViewHandler.test.ts +++ b/test/unit/identity/interaction/HtmlViewHandler.test.ts @@ -10,12 +10,18 @@ import { readableToString } from '../../../../src/util/StreamUtil'; import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; describe('An HtmlViewHandler', (): void => { + const idpIndex = 'http://example.com/idp/'; + let index: InteractionRoute; let operation: Operation; let templates: Record>; let templateEngine: TemplateEngine; let handler: HtmlViewHandler; beforeEach(async(): Promise => { + index = { + getPath: jest.fn().mockReturnValue(idpIndex), + } as any; + operation = { method: 'GET', target: { path: 'http://example.com/idp/login/' }, @@ -32,7 +38,7 @@ describe('An HtmlViewHandler', (): void => { render: jest.fn().mockReturnValue(Promise.resolve('')), }; - handler = new HtmlViewHandler(templateEngine, templates); + handler = new HtmlViewHandler(index, templateEngine, templates); }); it('rejects non-GET requests.', async(): Promise => { @@ -64,5 +70,17 @@ describe('An HtmlViewHandler', (): void => { const result = await handler.handle({ operation }); expect(result.metadata.contentType).toBe(TEXT_HTML); await expect(readableToString(result.data)).resolves.toBe(''); + expect(templateEngine.render).toHaveBeenCalledTimes(1); + expect(templateEngine.render) + .toHaveBeenLastCalledWith({ idpIndex, authenticating: false }, { templateFile: '/templates/login.html.ejs' }); + }); + + it('sets authenticating to true if there is an active interaction.', async(): Promise => { + const result = await handler.handle({ operation, oidcInteraction: {} as any }); + expect(result.metadata.contentType).toBe(TEXT_HTML); + await expect(readableToString(result.data)).resolves.toBe(''); + expect(templateEngine.render).toHaveBeenCalledTimes(1); + expect(templateEngine.render) + .toHaveBeenLastCalledWith({ idpIndex, authenticating: true }, { templateFile: '/templates/login.html.ejs' }); }); }); diff --git a/test/unit/identity/interaction/LocationInteractionHandler.test.ts b/test/unit/identity/interaction/LocationInteractionHandler.test.ts new file mode 100644 index 000000000..60125e90b --- /dev/null +++ b/test/unit/identity/interaction/LocationInteractionHandler.test.ts @@ -0,0 +1,62 @@ +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { + InteractionHandler, + InteractionHandlerInput, +} from '../../../../src/identity/interaction/InteractionHandler'; +import { LocationInteractionHandler } from '../../../../src/identity/interaction/LocationInteractionHandler'; +import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; +import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; +import { readJsonStream } from '../../../../src/util/StreamUtil'; + +describe('A LocationInteractionHandler', (): void => { + const representation = new BasicRepresentation(); + const input: InteractionHandlerInput = { + operation: { + target: { path: 'http://example.com/target' }, + preferences: {}, + method: 'GET', + body: new BasicRepresentation(), + }, + }; + let source: jest.Mocked; + let handler: LocationInteractionHandler; + + beforeEach(async(): Promise => { + source = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue(representation), + } as any; + + handler = new LocationInteractionHandler(source); + }); + + it('calls the source canHandle function.', async(): Promise => { + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + expect(source.canHandle).toHaveBeenCalledTimes(1); + expect(source.canHandle).toHaveBeenLastCalledWith(input); + + source.canHandle.mockRejectedValueOnce(new Error('bad input')); + await expect(handler.canHandle(input)).rejects.toThrow('bad input'); + }); + + it('returns the source output.', async(): Promise => { + await expect(handler.handle(input)).resolves.toBe(representation); + expect(source.handle).toHaveBeenCalledTimes(1); + expect(source.handle).toHaveBeenLastCalledWith(input); + }); + + it('returns a location object in case of redirect errors.', async(): Promise => { + const location = 'http://example.com/foo'; + source.handle.mockRejectedValueOnce(new FoundHttpError(location)); + + const response = await handler.handle(input); + expect(response.metadata.identifier.value).toEqual(input.operation.target.path); + await expect(readJsonStream(response.data)).resolves.toEqual({ location }); + }); + + it('rethrows non-redirect errors.', async(): Promise => { + source.handle.mockRejectedValueOnce(new NotFoundHttpError()); + + await expect(handler.handle(input)).rejects.toThrow(NotFoundHttpError); + }); +}); From 129d3c0ef13d437878d0c0b0f545cfd0156605d6 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Mon, 6 Dec 2021 13:49:39 +0100 Subject: [PATCH 23/39] test: Update IDP integration tests for new API --- test/integration/Identity.test.ts | 74 ++++++++++----------- test/integration/IdentityTestState.ts | 25 +++---- test/integration/RestrictedIdentity.test.ts | 5 +- 3 files changed, 51 insertions(+), 53 deletions(-) diff --git a/test/integration/Identity.test.ts b/test/integration/Identity.test.ts index 0561562e8..df242aae0 100644 --- a/test/integration/Identity.test.ts +++ b/test/integration/Identity.test.ts @@ -30,19 +30,6 @@ async function postForm(url: string, formBody: string): Promise { }); } -/** - * Extracts the registration triple from the registration form body. - */ -function extractRegistrationTriple(body: string, webId: string): string { - const error = load(body)('p.error').first().text(); - const regex = new RegExp( - `<${webId}>\\s+\\s+"[^"]+"\\s*\\.`, 'u', - ); - const match = regex.exec(error); - expect(match).toHaveLength(1); - return match![0]; -} - // No way around the cookies https://github.com/panva/node-oidc-provider/issues/552 . // They will be simulated by storing the values and passing them along. // This is why the redirects are handled manually. @@ -98,7 +85,8 @@ describe('A Solid server with IDP', (): void => { it('sends the form once to receive the registration triple.', async(): Promise => { const res = await postForm(`${baseUrl}idp/register/`, formBody); expect(res.status).toBe(400); - registrationTriple = extractRegistrationTriple(await res.text(), webId); + const json = await res.json(); + registrationTriple = json.details.quad; }); it('updates the webId with the registration token.', async(): Promise => { @@ -114,10 +102,11 @@ describe('A Solid server with IDP', (): void => { it('sends the form again to successfully register.', async(): Promise => { const res = await postForm(`${baseUrl}idp/register/`, formBody); expect(res.status).toBe(200); - const text = await res.text(); - expect(text).toMatch(new RegExp(`your.WebID.*${webId}`, 'u')); - expect(text).toMatch(new RegExp(`your.email.address.*${email}`, 'u')); - expect(text).toMatch(new RegExp(`<${webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${baseUrl}>\\.`, 'mu')); + await expect(res.json()).resolves.toEqual(expect.objectContaining({ + webId, + email, + oidcIssuer: baseUrl, + })); }); }); @@ -146,7 +135,8 @@ describe('A Solid server with IDP', (): void => { it('initializes the session and logs in.', async(): Promise => { const url = await state.startSession(); - await state.parseLoginPage(url); + const res = await state.fetchIdp(url); + expect(res.status).toBe(200); await state.login(url, email, password); expect(state.session.info?.webId).toBe(webId); }); @@ -171,11 +161,13 @@ describe('A Solid server with IDP', (): void => { let res = await state.fetchIdp(url); expect(res.status).toBe(200); + // Will receive confirm screen here instead of login screen res = await state.fetchIdp(url, 'POST', '', APPLICATION_X_WWW_FORM_URLENCODED); - const nextUrl = res.headers.get('location'); + const json = await res.json(); + const nextUrl = json.location; expect(typeof nextUrl).toBe('string'); - await state.handleLoginRedirect(nextUrl!); + await state.handleLoginRedirect(nextUrl); expect(state.session.info?.webId).toBe(webId); }); }); @@ -186,8 +178,8 @@ describe('A Solid server with IDP', (): void => { it('sends the corresponding email address through the form to get a mail.', async(): Promise => { const res = await postForm(`${baseUrl}idp/forgotpassword/`, stringify({ email })); expect(res.status).toBe(200); - expect(load(await res.text())('form p').first().text().trim()) - .toBe('If your account exists, an email has been sent with a link to reset your password.'); + const json = await res.json(); + expect(json.email).toBe(email); const mail = sendMail.mock.calls[0][0]; expect(mail.to).toBe(email); @@ -218,7 +210,6 @@ describe('A Solid server with IDP', (): void => { body: formData, }); expect(res.status).toBe(200); - expect(await res.text()).toContain('Your password was successfully reset.'); }); }); @@ -233,9 +224,10 @@ describe('A Solid server with IDP', (): void => { it('can not log in with the old password anymore.', async(): Promise => { const url = await state.startSession(); nextUrl = url; - await state.parseLoginPage(url); + let res = await state.fetchIdp(url); + expect(res.status).toBe(200); const formData = stringify({ email, password }); - const res = await state.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED); + res = await state.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED); expect(res.status).toBe(500); expect(await res.text()).toContain('Incorrect password'); }); @@ -266,7 +258,8 @@ describe('A Solid server with IDP', (): void => { it('sends the form once to receive the registration triple.', async(): Promise => { const res = await postForm(`${baseUrl}idp/register/`, formBody); expect(res.status).toBe(400); - registrationTriple = extractRegistrationTriple(await res.text(), webId2); + const json = await res.json(); + registrationTriple = json.details.quad; }); it('updates the webId with the registration token.', async(): Promise => { @@ -282,8 +275,11 @@ describe('A Solid server with IDP', (): void => { it('sends the form again to successfully register.', async(): Promise => { const res = await postForm(`${baseUrl}idp/register/`, formBody); expect(res.status).toBe(200); - const text = await res.text(); - expect(text).toMatch(new RegExp(`Your new Pod.*${baseUrl}${podName}/`, 'u')); + await expect(res.json()).resolves.toEqual(expect.objectContaining({ + email: 'bob@test.email', + webId: webId2, + podBaseUrl: `${baseUrl}${podName}/`, + })); }); }); @@ -300,21 +296,21 @@ describe('A Solid server with IDP', (): void => { it('sends the form to create the WebID and register.', async(): Promise => { const res = await postForm(`${baseUrl}idp/register/`, formBody); expect(res.status).toBe(200); - const text = await res.text(); - - const matchWebId = /Your new WebID is [^>]+>([^<]+)/u.exec(text); - expect(matchWebId).toBeDefined(); - expect(matchWebId).toHaveLength(2); - newWebId = matchWebId![1]; - expect(text).toMatch(new RegExp(`new WebID is.*${newWebId}`, 'u')); - expect(text).toMatch(new RegExp(`your email address.*${newMail}`, 'u')); - expect(text).toMatch(new RegExp(`Your new Pod.*${baseUrl}${podName}/`, 'u')); + const json = await res.json(); + expect(json).toEqual(expect.objectContaining({ + webId: expect.any(String), + email: newMail, + oidcIssuer: baseUrl, + podBaseUrl: `${baseUrl}${podName}/`, + })); + newWebId = json.webId; }); it('initializes the session and logs in.', async(): Promise => { state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer); const url = await state.startSession(); - await state.parseLoginPage(url); + const res = await state.fetchIdp(url); + expect(res.status).toBe(200); await state.login(url, newMail, password); expect(state.session.info?.webId).toBe(newWebId); }); diff --git a/test/integration/IdentityTestState.ts b/test/integration/IdentityTestState.ts index 28597d006..59816a44a 100644 --- a/test/integration/IdentityTestState.ts +++ b/test/integration/IdentityTestState.ts @@ -87,21 +87,21 @@ export class IdentityTestState { expect(nextUrl.startsWith(this.oidcIssuer)).toBeTruthy(); // Need to catch the redirect so we can copy the cookies - const res = await this.fetchIdp(nextUrl); + let res = await this.fetchIdp(nextUrl); expect(res.status).toBe(302); nextUrl = res.headers.get('location')!; - return nextUrl; - } - - public async parseLoginPage(url: string): Promise<{ register: string; forgotPassword: string }> { - const res = await this.fetchIdp(url); + // Handle redirect + res = await this.fetchIdp(nextUrl); expect(res.status).toBe(200); - const text = await res.text(); - const register = this.extractUrl(text, 'a:contains("Sign up")', 'href'); - const forgotPassword = this.extractUrl(text, 'a:contains("Forgot password")', 'href'); - return { register, forgotPassword }; + // Need to send request to prompt API to get actual location + let json = await res.json(); + res = await this.fetchIdp(json.controls.prompt); + json = await res.json(); + nextUrl = json.location; + + return nextUrl; } /** @@ -111,8 +111,9 @@ export class IdentityTestState { public async login(url: string, email: string, password: string): Promise { const formData = stringify({ email, password }); const res = await this.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED); - expect(res.status).toBe(302); - const nextUrl = res.headers.get('location')!; + expect(res.status).toBe(200); + const json = await res.json(); + const nextUrl = json.location; return this.handleLoginRedirect(nextUrl); } diff --git a/test/integration/RestrictedIdentity.test.ts b/test/integration/RestrictedIdentity.test.ts index 9a0db6593..a9fee024f 100644 --- a/test/integration/RestrictedIdentity.test.ts +++ b/test/integration/RestrictedIdentity.test.ts @@ -95,12 +95,13 @@ describe('A server with restricted IDP access', (): void => { // Logging into session const state = new IdentityTestState(baseUrl, 'http://mockedredirect/', baseUrl); const url = await state.startSession(); - await state.parseLoginPage(url); + let res = await state.fetchIdp(url); + expect(res.status).toBe(200); await state.login(url, settings.email, settings.password); expect(state.session.info?.webId).toBe(webId); // Registration still works for this WebID - let res = await state.session.fetch(`${baseUrl}idp/register/`); + res = await state.session.fetch(`${baseUrl}idp/register/`); expect(res.status).toBe(200); res = await state.session.fetch(`${baseUrl}idp/register/`, { From 95777914729890debe0d4815c084029864afaf23 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 7 Dec 2021 16:03:03 +0100 Subject: [PATCH 24/39] feat: Simplify setup to be more in line with IDP behaviour --- config/app/setup/handlers/setup.json | 27 ++- src/index.ts | 1 + src/init/setup/SetupHandler.ts | 83 +++++++ src/init/setup/SetupHttpHandler.ts | 179 ++++----------- templates/setup/index.html.ejs | 105 ++++----- templates/setup/input-partial.html.ejs | 69 ++++++ templates/setup/response.html.ejs | 54 ----- test/integration/Setup.test.ts | 33 ++- test/unit/init/setup/SetupHandler.test.ts | 88 +++++++ test/unit/init/setup/SetupHttpHandler.test.ts | 214 +++++------------- 10 files changed, 420 insertions(+), 433 deletions(-) create mode 100644 src/init/setup/SetupHandler.ts create mode 100644 templates/setup/input-partial.html.ejs delete mode 100644 templates/setup/response.html.ejs create mode 100644 test/unit/init/setup/SetupHandler.test.ts diff --git a/config/app/setup/handlers/setup.json b/config/app/setup/handlers/setup.json index fd0bbd140..ea279ffd3 100644 --- a/config/app/setup/handlers/setup.json +++ b/config/app/setup/handlers/setup.json @@ -14,14 +14,31 @@ "args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }, "args_operationHandler": { "@type": "SetupHttpHandler", - "args_initializer": { "@id": "urn:solid-server:default:RootInitializer" }, - "args_registrationManager": { "@id": "urn:solid-server:default:SetupRegistrationManager" }, + "args_handler": { + "@type": "SetupHandler", + "args_initializer": { "@id": "urn:solid-server:default:RootInitializer" }, + "args_registrationManager": { "@id": "urn:solid-server:default:SetupRegistrationManager" } + }, "args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, "args_storageKey": "setupCompleted-2.0", "args_storage": { "@id": "urn:solid-server:default:SetupStorage" }, - "args_viewTemplate": "@css:templates/setup/index.html.ejs", - "args_responseTemplate": "@css:templates/setup/response.html.ejs", - "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" } + "args_templateEngine": { + "comment": "Renders the specific page and embeds it into the main HTML body.", + "@type": "ChainedTemplateEngine", + "renderedName": "htmlBody", + "engines": [ + { + "comment": "Renders the main setup template.", + "@type": "EjsTemplateEngine", + "template": "@css:templates/setup/index.html.ejs" + }, + { + "comment": "Will embed the result of the first engine into the main HTML template.", + "@type": "EjsTemplateEngine", + "template": "@css:templates/main.html.ejs" + } + ] + } } }, { diff --git a/src/index.ts b/src/index.ts index 4b056a28e..2d68312c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -185,6 +185,7 @@ export * from './init/final/Finalizable'; export * from './init/final/ParallelFinalizer'; // Init/Setup +export * from './init/setup/SetupHandler'; export * from './init/setup/SetupHttpHandler'; // Init/Cli diff --git a/src/init/setup/SetupHandler.ts b/src/init/setup/SetupHandler.ts new file mode 100644 index 000000000..9d7f798f4 --- /dev/null +++ b/src/init/setup/SetupHandler.ts @@ -0,0 +1,83 @@ +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { BaseInteractionHandler } from '../../identity/interaction/BaseInteractionHandler'; +import type { RegistrationManager } from '../../identity/interaction/email-password/util/RegistrationManager'; +import type { InteractionHandlerInput } from '../../identity/interaction/InteractionHandler'; +import { getLoggerFor } from '../../logging/LogUtil'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { readJsonStream } from '../../util/StreamUtil'; +import type { Initializer } from '../Initializer'; + +export interface SetupHandlerArgs { + /** + * Used for registering a pod during setup. + */ + registrationManager?: RegistrationManager; + /** + * Initializer to call in case no registration procedure needs to happen. + * This Initializer should make sure the necessary resources are there so the server can work correctly. + */ + initializer?: Initializer; +} + +/** + * On POST requests, runs an initializer and/or performs a registration step, both optional. + */ +export class SetupHandler extends BaseInteractionHandler { + protected readonly logger = getLoggerFor(this); + + private readonly registrationManager?: RegistrationManager; + private readonly initializer?: Initializer; + + public constructor(args: SetupHandlerArgs) { + super({}); + this.registrationManager = args.registrationManager; + this.initializer = args.initializer; + } + + protected async handlePost({ operation }: InteractionHandlerInput): Promise { + const json = operation.body.isEmpty ? {} : await readJsonStream(operation.body.data); + + const output: Record = { initialize: false, registration: false }; + if (json.registration) { + Object.assign(output, await this.register(json)); + output.registration = true; + } else if (json.initialize) { + // We only want to initialize if no registration happened + await this.initialize(); + output.initialize = true; + } + + this.logger.debug(`Output: ${JSON.stringify(output)}`); + + return new BasicRepresentation(JSON.stringify(output), APPLICATION_JSON); + } + + /** + * Call the initializer. + * Errors if no initializer was defined. + */ + private async initialize(): Promise { + if (!this.initializer) { + throw new NotImplementedHttpError('This server is not configured with a setup initializer.'); + } + await this.initializer.handleSafe(); + } + + /** + * Register a user based on the given input. + * Errors if no registration manager is defined. + */ + private async register(json: NodeJS.Dict): Promise> { + if (!this.registrationManager) { + throw new NotImplementedHttpError('This server is not configured to support registration during setup.'); + } + // Validate the input JSON + const validated = this.registrationManager.validateInput(json, true); + this.logger.debug(`Validated input: ${JSON.stringify(validated)}`); + + // Register and/or create a pod as requested. Potentially does nothing if all booleans are false. + return this.registrationManager.register(validated, true); + } +} diff --git a/src/init/setup/SetupHttpHandler.ts b/src/init/setup/SetupHttpHandler.ts index ffb3a1c79..ebc7730d9 100644 --- a/src/init/setup/SetupHttpHandler.ts +++ b/src/init/setup/SetupHttpHandler.ts @@ -1,55 +1,26 @@ import type { Operation } from '../../http/Operation'; -import type { ErrorHandler } from '../../http/output/error/ErrorHandler'; -import { ResponseDescription } from '../../http/output/response/ResponseDescription'; +import { OkResponseDescription } from '../../http/output/response/OkResponseDescription'; +import type { ResponseDescription } from '../../http/output/response/ResponseDescription'; import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; -import type { RegistrationParams, - RegistrationManager } from '../../identity/interaction/email-password/util/RegistrationManager'; +import type { InteractionHandler } from '../../identity/interaction/InteractionHandler'; import { getLoggerFor } from '../../logging/LogUtil'; import type { OperationHttpHandlerInput } from '../../server/OperationHttpHandler'; import { OperationHttpHandler } from '../../server/OperationHttpHandler'; import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter'; import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage'; import { APPLICATION_JSON, TEXT_HTML } from '../../util/ContentTypes'; -import { createErrorMessage } from '../../util/errors/ErrorUtil'; -import { HttpError } from '../../util/errors/HttpError'; -import { InternalServerError } from '../../util/errors/InternalServerError'; import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError'; -import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; -import { addTemplateMetadata } from '../../util/ResourceUtil'; -import { readJsonStream } from '../../util/StreamUtil'; -import type { Initializer } from '../Initializer'; - -/** - * Input parameters expected in calls to the handler. - * Will be sent to the RegistrationManager for validation and registration. - * The reason this is a flat object and does not have a specific field for all the registration parameters - * is so we can also support form data. - */ -export interface SetupInput extends Record{ - /** - * Indicates if the initializer should be executed. Ignored if `registration` is true. - */ - initialize?: boolean; - /** - * Indicates if the registration procedure should be done for IDP registration and/or pod provisioning. - */ - registration?: boolean; -} +import type { TemplateEngine } from '../../util/templates/TemplateEngine'; export interface SetupHttpHandlerArgs { /** - * Used for registering a pod during setup. - */ - registrationManager?: RegistrationManager; - /** - * Initializer to call in case no registration procedure needs to happen. - * This Initializer should make sure the necessary resources are there so the server can work correctly. - */ - initializer?: Initializer; - /** - * Used for content negotiation. + * Used for converting the input data. */ converter: RepresentationConverter; + /** + * Handles the requests. + */ + handler: InteractionHandler; /** * Key that is used to store the boolean in the storage indicating setup is finished. */ @@ -59,17 +30,9 @@ export interface SetupHttpHandlerArgs { */ storage: KeyValueStorage; /** - * Template to use for GET requests. + * Renders the main view. */ - viewTemplate: string; - /** - * Template to show when setup was completed successfully. - */ - responseTemplate: string; - /** - * Used for converting output errors. - */ - errorHandler: ErrorHandler; + templateEngine: TemplateEngine; } /** @@ -78,128 +41,68 @@ export interface SetupHttpHandlerArgs { * this to prevent accidentally running unsafe servers. * * GET requests will return the view template which should contain the setup information for the user. - * POST requests will run an initializer and/or perform a registration step, both optional. + * POST requests will be sent to the InteractionHandler. * After successfully completing a POST request this handler will disable itself and become unreachable. * All other methods will be rejected. */ export class SetupHttpHandler extends OperationHttpHandler { protected readonly logger = getLoggerFor(this); - private readonly registrationManager?: RegistrationManager; - private readonly initializer?: Initializer; + private readonly handler: InteractionHandler; private readonly converter: RepresentationConverter; private readonly storageKey: string; private readonly storage: KeyValueStorage; - private readonly viewTemplate: string; - private readonly responseTemplate: string; - private readonly errorHandler: ErrorHandler; - - private finished: boolean; + private readonly templateEngine: TemplateEngine; public constructor(args: SetupHttpHandlerArgs) { super(); - this.finished = false; - this.registrationManager = args.registrationManager; - this.initializer = args.initializer; + this.handler = args.handler; this.converter = args.converter; this.storageKey = args.storageKey; this.storage = args.storage; - this.viewTemplate = args.viewTemplate; - this.responseTemplate = args.responseTemplate; - this.errorHandler = args.errorHandler; + this.templateEngine = args.templateEngine; } public async handle({ operation }: OperationHttpHandlerInput): Promise { - let json: Record; - let template: string; - let success = false; - let statusCode = 200; - try { - ({ json, template } = await this.getJsonResult(operation)); - success = true; - } catch (err: unknown) { - // We want to show the errors on the original page in case of HTML interactions, so we can't just throw them here - const error = HttpError.isInstance(err) ? err : new InternalServerError(createErrorMessage(err)); - ({ statusCode } = error); - this.logger.warn(error.message); - const response = await this.errorHandler.handleSafe({ error, preferences: { type: { [APPLICATION_JSON]: 1 }}}); - json = await readJsonStream(response.data!); - template = this.viewTemplate; + switch (operation.method) { + case 'GET': return this.handleGet(operation); + case 'POST': return this.handlePost(operation); + default: throw new MethodNotAllowedHttpError(); } - - // Convert the response JSON to the required format - const representation = new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON); - addTemplateMetadata(representation.metadata, template, TEXT_HTML); - const result = await this.converter.handleSafe( - { representation, identifier: operation.target, preferences: operation.preferences }, - ); - - // Make sure this setup handler is never used again after a successful POST request - if (success && operation.method === 'POST') { - this.finished = true; - await this.storage.set(this.storageKey, true); - } - - return new ResponseDescription(statusCode, result.metadata, result.data); } /** - * Creates a JSON object representing the result of executing the given operation, - * together with the template it should be applied to. + * Returns the HTML representation of the setup page. */ - private async getJsonResult(operation: Operation): Promise<{ json: Record; template: string }> { - if (operation.method === 'GET') { - // Return the initial setup page - return { json: {}, template: this.viewTemplate }; - } - if (operation.method !== 'POST') { - throw new MethodNotAllowedHttpError(); - } + private async handleGet(operation: Operation): Promise { + const result = await this.templateEngine.render({}); + const representation = new BasicRepresentation(result, operation.target, TEXT_HTML); + return new OkResponseDescription(representation.metadata, representation.data); + } - // Registration manager expects JSON data - let json: SetupInput = {}; - if (!operation.body.isEmpty) { + /** + * Converts the input data to JSON and calls the setup handler. + * On success `true` will be written to the storage key. + */ + private async handlePost(operation: Operation): Promise { + // Convert input data to JSON + // Allows us to still support form data + if (operation.body.metadata.contentType) { const args = { representation: operation.body, preferences: { type: { [APPLICATION_JSON]: 1 }}, identifier: operation.target, }; - const converted = await this.converter.handleSafe(args); - json = await readJsonStream(converted.data); - this.logger.debug(`Input JSON: ${JSON.stringify(json)}`); + operation = { + ...operation, + body: await this.converter.handleSafe(args), + }; } - // We want to initialize after the input has been validated, but before (potentially) writing a pod - // since that might overwrite the initializer result - if (json.initialize && !json.registration) { - if (!this.initializer) { - throw new NotImplementedHttpError('This server is not configured with a setup initializer.'); - } - await this.initializer.handleSafe(); - } + const representation = await this.handler.handleSafe({ operation }); + await this.storage.set(this.storageKey, true); - let output: Record = {}; - // We only call the RegistrationManager when getting registration input. - // This way it is also possible to set up a server without requiring registration parameters. - let validated: RegistrationParams | undefined; - if (json.registration) { - if (!this.registrationManager) { - throw new NotImplementedHttpError('This server is not configured to support registration during setup.'); - } - // Validate the input JSON - validated = this.registrationManager.validateInput(json, true); - this.logger.debug(`Validated input: ${JSON.stringify(validated)}`); - - // Register and/or create a pod as requested. Potentially does nothing if all booleans are false. - output = await this.registrationManager.register(validated, true); - } - - // Add extra setup metadata - output.initialize = Boolean(json.initialize); - output.registration = Boolean(json.registration); - this.logger.debug(`Output: ${JSON.stringify(output)}`); - - return { json: output, template: this.responseTemplate }; + return new OkResponseDescription(representation.metadata, representation.data); } } diff --git a/templates/setup/index.html.ejs b/templates/setup/index.html.ejs index acdb998b8..b5b9923fb 100644 --- a/templates/setup/index.html.ejs +++ b/templates/setup/index.html.ejs @@ -1,72 +1,45 @@ -

    Set up your Solid server

    -

    - Your Solid server needs a one-time setup - so it acts exactly the way you want. -

    +
    + <%- include('./input-partial.html.ejs') %> +
    +
    +

    Server setup complete

    +

    + Congratulations! + Your Solid server is now ready to use. +
    + You can now visit its homepage. +

    -
    - <% const safePrefilled = locals.prefilled || {}; %> +
    +

    Root Pod

    +

    + Warning: the root Pod is publicly accessible. +
    + Prevent public write and control access to the root + by modifying its ACL document. +

    +
    - <% if (locals.message) { %> -

    <%= message %>

    - <% } %> -
    - Accounts on this server -
      -
    1. - -

      - You can disable account registration - by changing the configuration. -

      -
    2. -
    3. - -

      - Any existing root Pod will be disabled. -

      -
    4. -
    5. - -

      - By default, the public has read and write access to the root Pod. -
      - You typically only want to choose this - for rapid testing and development. -

      -
    6. -
    -
    +
    + <%- include('../identity/email-password/register-response-partial.html.ejs', { idpIndex: '' }) %> +
    +
    -
    - Sign up - <%- - include('../identity/email-password/register-partial.html.ejs', { - allowRoot: true, - }) - %> -
    - -

    - - - diff --git a/templates/setup/input-partial.html.ejs b/templates/setup/input-partial.html.ejs new file mode 100644 index 000000000..bf9eb785f --- /dev/null +++ b/templates/setup/input-partial.html.ejs @@ -0,0 +1,69 @@ +

    Set up your Solid server

    +

    + Your Solid server needs a one-time setup + so it acts exactly the way you want. +

    + +
    +

    + +
    + Accounts on this server +
      +
    1. + +

      + You can disable account registration + by changing the configuration. +

      +
    2. +
    3. + +

      + Any existing root Pod will be disabled. +

      +
    4. +
    5. + +

      + By default, the public has read and write access to the root Pod. +
      + You typically only want to choose this + for rapid testing and development. +

      +
    6. +
    +
    + +
    + Sign up + <%- + include('../identity/email-password/register-partial.html.ejs', { + allowRoot: true, + }) + %> +
    + +

    +
    + + + diff --git a/templates/setup/response.html.ejs b/templates/setup/response.html.ejs deleted file mode 100644 index fd0764d55..000000000 --- a/templates/setup/response.html.ejs +++ /dev/null @@ -1,54 +0,0 @@ -

    Server setup complete

    -

    - Congratulations! - Your Solid server is now ready to use. -
    - You can now visit its homepage. -

    - -<% if (initialize && !registration) { %> -

    Root Pod

    -

    - Warning: the root Pod is publicly accessible. -
    - Prevent public write and control access to the root - by modifying its ACL document. -

    -<% } %> - -<% if (registration) { %> - <% if (createPod) { %> -

    Your new Pod

    -

    - Your new Pod is located at <%= podBaseUrl %>. -
    - You can store your documents and data there. -

    - <% } %> - - <% if (createWebId) { %> -

    Your new WebID

    -

    - Your new WebID is <%= webId %>. -
    - You can use this identifier to interact with Solid pods and apps. -

    - <% } %> - - <% if (register) { %> -

    Your new account

    -

    - Via your email address <%= email %>, - this server lets you log in to Solid apps - with your WebID <%= webId %> -

    - <% if (!createWebId) { %> -

    - You will need to add the triple - <%= `<${webId}> <${oidcIssuer}>.`%> - to your existing WebID document <%= webId %> - to indicate that you trust this server as a login provider. -

    - <% } %> - <% } %> -<% } %> diff --git a/test/integration/Setup.test.ts b/test/integration/Setup.test.ts index b4be03ef3..f8976e975 100644 --- a/test/integration/Setup.test.ts +++ b/test/integration/Setup.test.ts @@ -33,21 +33,24 @@ describe('A Solid server with setup', (): void => { it('catches all requests.', async(): Promise => { let res = await fetch(baseUrl, { method: 'GET', headers: { accept: 'text/html' }}); expect(res.status).toBe(200); + expect(res.url).toBe(setupUrl); await expect(res.text()).resolves.toContain('Set up your Solid server'); res = await fetch(joinUrl(baseUrl, '/random/path/'), { method: 'GET', headers: { accept: 'text/html' }}); expect(res.status).toBe(200); + expect(res.url).toBe(setupUrl); await expect(res.text()).resolves.toContain('Set up your Solid server'); - res = await fetch(joinUrl(baseUrl, '/random/path/'), { method: 'PUT', headers: { accept: 'text/html' }}); + res = await fetch(joinUrl(baseUrl, '/random/path/'), { method: 'PUT' }); expect(res.status).toBe(405); - await expect(res.text()).resolves.toContain('Set up your Solid server'); + expect(res.url).toBe(setupUrl); + await expect(res.json()).resolves.toEqual(expect.objectContaining({ name: 'MethodNotAllowedHttpError' })); }); it('can create a server that disables root but allows registration.', async(): Promise => { - let res = await fetch(setupUrl, { method: 'POST', headers: { accept: 'text/html' }}); + let res = await fetch(setupUrl, { method: 'POST' }); expect(res.status).toBe(200); - await expect(res.text()).resolves.toContain('Server setup complete'); + await expect(res.json()).resolves.toEqual({ initialize: false, registration: false }); // Root access disabled res = await fetch(baseUrl); @@ -57,7 +60,7 @@ describe('A Solid server with setup', (): void => { const registerParams = { email, podName, password, confirmPassword: password, createWebId: true }; res = await fetch(joinUrl(baseUrl, 'idp/register/'), { method: 'POST', - headers: { accept: 'text/html', 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json' }, body: JSON.stringify(registerParams), }); expect(res.status).toBe(200); @@ -70,11 +73,11 @@ describe('A Solid server with setup', (): void => { it('can create a server with a public root.', async(): Promise => { let res = await fetch(setupUrl, { method: 'POST', - headers: { accept: 'text/html', 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ initialize: true }), }); expect(res.status).toBe(200); - await expect(res.text()).resolves.toContain('Server setup complete'); + await expect(res.json()).resolves.toEqual({ initialize: true, registration: false }); // Root access enabled res = await fetch(baseUrl); @@ -85,7 +88,7 @@ describe('A Solid server with setup', (): void => { const registerParams = { email, podName, password, confirmPassword: password, createWebId: true, rootPod: true }; res = await fetch(joinUrl(baseUrl, 'idp/register/'), { method: 'POST', - headers: { accept: 'text/html', 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json' }, body: JSON.stringify(registerParams), }); expect(res.status).toBe(500); @@ -95,11 +98,19 @@ describe('A Solid server with setup', (): void => { const registerParams = { email, podName, password, confirmPassword: password, createWebId: true, rootPod: true }; let res = await fetch(setupUrl, { method: 'POST', - headers: { accept: 'text/html', 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ registration: true, initialize: true, ...registerParams }), }); expect(res.status).toBe(200); - await expect(res.text()).resolves.toContain('Server setup complete'); + const json = await res.json(); + expect(json).toEqual(expect.objectContaining({ + registration: true, + initialize: false, + oidcIssuer: baseUrl, + webId: `${baseUrl}profile/card#me`, + email, + podBaseUrl: baseUrl, + })); // Root profile created res = await fetch(joinUrl(baseUrl, '/profile/card')); @@ -109,7 +120,7 @@ describe('A Solid server with setup', (): void => { // Pod root is not accessible even though initialize was set to true res = await fetch(joinUrl(baseUrl, 'resource'), { method: 'PUT', - headers: { accept: 'text/html', 'content-type': 'text/plain' }, + headers: { 'content-type': 'text/plain' }, body: 'random data', }); expect(res.status).toBe(401); diff --git a/test/unit/init/setup/SetupHandler.test.ts b/test/unit/init/setup/SetupHandler.test.ts new file mode 100644 index 000000000..e703e273b --- /dev/null +++ b/test/unit/init/setup/SetupHandler.test.ts @@ -0,0 +1,88 @@ +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { RegistrationResponse, + RegistrationManager } from '../../../../src/identity/interaction/email-password/util/RegistrationManager'; +import type { Initializer } from '../../../../src/init/Initializer'; +import { SetupHandler } from '../../../../src/init/setup/SetupHandler'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import { readJsonStream } from '../../../../src/util/StreamUtil'; + +describe('A SetupHandler', (): void => { + let operation: Operation; + let details: RegistrationResponse; + let registrationManager: jest.Mocked; + let initializer: jest.Mocked; + let handler: SetupHandler; + + beforeEach(async(): Promise => { + operation = { + method: 'POST', + target: { path: 'http://example.com/setup' }, + preferences: {}, + body: new BasicRepresentation(), + }; + + initializer = { + handleSafe: jest.fn(), + } as any; + + details = { + email: 'alice@test.email', + createWebId: true, + register: true, + createPod: true, + }; + + registrationManager = { + validateInput: jest.fn((input): any => input), + register: jest.fn().mockResolvedValue(details), + } as any; + + handler = new SetupHandler({ registrationManager, initializer }); + }); + + it('error if no Initializer is defined and initialization is requested.', async(): Promise => { + handler = new SetupHandler({}); + operation.body = new BasicRepresentation(JSON.stringify({ initialize: true }), 'application/json'); + await expect(handler.handle({ operation })).rejects.toThrow(NotImplementedHttpError); + }); + + it('error if no RegistrationManager is defined and registration is requested.', async(): Promise => { + handler = new SetupHandler({}); + operation.body = new BasicRepresentation(JSON.stringify({ registration: true }), 'application/json'); + await expect(handler.handle({ operation })).rejects.toThrow(NotImplementedHttpError); + }); + + it('calls the Initializer when requested.', async(): Promise => { + operation.body = new BasicRepresentation(JSON.stringify({ initialize: true }), 'application/json'); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ initialize: true, registration: false }); + expect(result.metadata.contentType).toBe('application/json'); + expect(initializer.handleSafe).toHaveBeenCalledTimes(1); + expect(registrationManager.validateInput).toHaveBeenCalledTimes(0); + expect(registrationManager.register).toHaveBeenCalledTimes(0); + }); + + it('calls the RegistrationManager when requested.', async(): Promise => { + const body = { registration: true, email: 'test@example.com' }; + operation.body = new BasicRepresentation(JSON.stringify(body), 'application/json'); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ initialize: false, registration: true, ...details }); + expect(result.metadata.contentType).toBe('application/json'); + expect(initializer.handleSafe).toHaveBeenCalledTimes(0); + expect(registrationManager.validateInput).toHaveBeenCalledTimes(1); + expect(registrationManager.register).toHaveBeenCalledTimes(1); + expect(registrationManager.validateInput).toHaveBeenLastCalledWith(body, true); + expect(registrationManager.register).toHaveBeenLastCalledWith(body, true); + }); + + it('defaults to an empty JSON body if no data is provided.', async(): Promise => { + operation.body = new BasicRepresentation(); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ initialize: false, registration: false }); + expect(result.metadata.contentType).toBe('application/json'); + expect(initializer.handleSafe).toHaveBeenCalledTimes(0); + expect(registrationManager.validateInput).toHaveBeenCalledTimes(0); + expect(registrationManager.register).toHaveBeenCalledTimes(0); + }); +}); diff --git a/test/unit/init/setup/SetupHttpHandler.test.ts b/test/unit/init/setup/SetupHttpHandler.test.ts index d58bcf510..b1cf83b33 100644 --- a/test/unit/init/setup/SetupHttpHandler.test.ts +++ b/test/unit/init/setup/SetupHttpHandler.test.ts @@ -1,13 +1,8 @@ import type { Operation } from '../../../../src/http/Operation'; -import type { ErrorHandler, ErrorHandlerArgs } from '../../../../src/http/output/error/ErrorHandler'; -import type { ResponseDescription } from '../../../../src/http/output/response/ResponseDescription'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../../src/http/representation/Representation'; import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; -import type { RegistrationManager, - RegistrationResponse } from '../../../../src/identity/interaction/email-password/util/RegistrationManager'; -import type { Initializer } from '../../../../src/init/Initializer'; -import type { SetupInput } from '../../../../src/init/setup/SetupHttpHandler'; +import type { InteractionHandler } from '../../../../src/identity/interaction/InteractionHandler'; import { SetupHttpHandler } from '../../../../src/init/setup/SetupHttpHandler'; import type { HttpRequest } from '../../../../src/server/HttpRequest'; import type { HttpResponse } from '../../../../src/server/HttpResponse'; @@ -15,25 +10,20 @@ import { getBestPreference } from '../../../../src/storage/conversion/Conversion import type { RepresentationConverterArgs, RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter'; import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; -import { APPLICATION_JSON } from '../../../../src/util/ContentTypes'; -import type { HttpError } from '../../../../src/util/errors/HttpError'; -import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; +import { APPLICATION_JSON, APPLICATION_X_WWW_FORM_URLENCODED } from '../../../../src/util/ContentTypes'; import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError'; -import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; -import { guardedStreamFrom, readableToString } from '../../../../src/util/StreamUtil'; -import { CONTENT_TYPE, SOLID_META } from '../../../../src/util/Vocabularies'; +import { readableToString } from '../../../../src/util/StreamUtil'; +import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; +import { CONTENT_TYPE } from '../../../../src/util/Vocabularies'; describe('A SetupHttpHandler', (): void => { - let request: HttpRequest; + const request: HttpRequest = {} as any; const response: HttpResponse = {} as any; let operation: Operation; - const viewTemplate = '/templates/view'; - const responseTemplate = '/templates/response'; const storageKey = 'completed'; - let details: RegistrationResponse; - let errorHandler: jest.Mocked; - let registrationManager: jest.Mocked; - let initializer: jest.Mocked; + let representation: Representation; + let interactionHandler: jest.Mocked; + let templateEngine: jest.Mocked; let converter: jest.Mocked; let storage: jest.Mocked>; let handler: SetupHttpHandler; @@ -41,32 +31,15 @@ describe('A SetupHttpHandler', (): void => { beforeEach(async(): Promise => { operation = { method: 'GET', - target: { path: 'http://test.com/setup' }, - preferences: { type: { 'text/html': 1 }}, + target: { path: 'http://example.com/setup' }, + preferences: {}, body: new BasicRepresentation(), }; - errorHandler = { handleSafe: jest.fn(({ error }: ErrorHandlerArgs): ResponseDescription => ({ - statusCode: 400, - data: guardedStreamFrom(`{ "name": "${error.name}", "message": "${error.message}" }`), - })) } as any; - - initializer = { - handleSafe: jest.fn(), - } as any; - - details = { - email: 'alice@test.email', - createWebId: true, - register: true, - createPod: true, + templateEngine = { + render: jest.fn().mockReturnValue(Promise.resolve('')), }; - registrationManager = { - validateInput: jest.fn((input): any => input), - register: jest.fn().mockResolvedValue(details), - } as any; - converter = { handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => { // Just find the best match; @@ -76,148 +49,71 @@ describe('A SetupHttpHandler', (): void => { }), } as any; + representation = new BasicRepresentation(); + interactionHandler = { + handleSafe: jest.fn().mockResolvedValue(representation), + } as any; + storage = new Map() as any; handler = new SetupHttpHandler({ - initializer, - registrationManager, converter, storageKey, storage, - viewTemplate, - responseTemplate, - errorHandler, + handler: interactionHandler, + templateEngine, }); }); - // Since all tests check similar things, the test functionality is generalized in here - async function testPost(input: SetupInput, error?: HttpError): Promise { - operation.method = 'POST'; - const initialize = Boolean(input.initialize); - const registration = Boolean(input.registration); - const requestBody = { initialize, registration }; - if (Object.keys(input).length > 0) { - operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json'); - } + it('only accepts GET and POST operations.', async(): Promise => { + operation = { + method: 'DELETE', + target: { path: 'http://example.com/setup' }, + preferences: {}, + body: new BasicRepresentation(), + }; + await expect(handler.handle({ operation, request, response })).rejects.toThrow(MethodNotAllowedHttpError); + }); + it('calls the template engine for GET requests.', async(): Promise => { const result = await handler.handle({ operation, request, response }); - expect(result).toBeDefined(); - expect(initializer.handleSafe).toHaveBeenCalledTimes(!error && initialize ? 1 : 0); - expect(registrationManager.validateInput).toHaveBeenCalledTimes(!error && registration ? 1 : 0); - expect(registrationManager.register).toHaveBeenCalledTimes(!error && registration ? 1 : 0); - let expectedResult: any = { initialize, registration }; - if (error) { - expectedResult = { name: error.name, message: error.message }; - } else if (registration) { - Object.assign(expectedResult, details); - } - expect(JSON.parse(await readableToString(result.data!))).toEqual(expectedResult); - expect(result.statusCode).toBe(error?.statusCode ?? 200); + expect(result.data).toBeDefined(); + await expect(readableToString(result.data!)).resolves.toBe(''); expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe(error ? viewTemplate : responseTemplate); - - if (!error && registration) { - expect(registrationManager.validateInput).toHaveBeenLastCalledWith(requestBody, true); - expect(registrationManager.register).toHaveBeenLastCalledWith(requestBody, true); - } - } - - it('returns the view template on GET requests.', async(): Promise => { - const result = await handler.handle({ operation, request, response }); - expect(result).toBeDefined(); - expect(JSON.parse(await readableToString(result.data!))).toEqual({}); - expect(result.statusCode).toBe(200); - expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe(viewTemplate); // Setup is still enabled since this was a GET request expect(storage.get(storageKey)).toBeUndefined(); }); - it('simply disables the handler if no setup is requested.', async(): Promise => { - await expect(testPost({ initialize: false, registration: false })).resolves.toBeUndefined(); + it('returns the handler result as 200 response.', async(): Promise => { + operation.method = 'POST'; + const result = await handler.handle({ operation, request, response }); + expect(result.statusCode).toBe(200); + expect(result.data).toBe(representation.data); + expect(result.metadata).toBe(representation.metadata); + expect(interactionHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(interactionHandler.handleSafe).toHaveBeenLastCalledWith({ operation }); // Handler is now disabled due to successful POST expect(storage.get(storageKey)).toBe(true); }); - it('defaults to an empty body if there is none.', async(): Promise => { - await expect(testPost({})).resolves.toBeUndefined(); - }); - - it('calls the initializer when requested.', async(): Promise => { - await expect(testPost({ initialize: true, registration: false })).resolves.toBeUndefined(); - }); - - it('calls the registrationManager when requested.', async(): Promise => { - await expect(testPost({ initialize: false, registration: true })).resolves.toBeUndefined(); - }); - - it('converts non-HTTP errors to internal errors.', async(): Promise => { - converter.handleSafe.mockRejectedValueOnce(new Error('bad data')); - const error = new InternalServerError('bad data'); - await expect(testPost({ initialize: true, registration: false }, error)).resolves.toBeUndefined(); - }); - - it('errors on non-GET/POST requests.', async(): Promise => { - operation.method = 'PUT'; - const requestBody = { initialize: true, registration: true }; - operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json'); - const error = new MethodNotAllowedHttpError(); - + it('converts input bodies to JSON.', async(): Promise => { + operation.method = 'POST'; + operation.body.metadata.contentType = APPLICATION_X_WWW_FORM_URLENCODED; const result = await handler.handle({ operation, request, response }); - expect(result).toBeDefined(); - expect(initializer.handleSafe).toHaveBeenCalledTimes(0); - expect(registrationManager.register).toHaveBeenCalledTimes(0); - expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1); - expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { [APPLICATION_JSON]: 1 }}}); + expect(result.statusCode).toBe(200); + expect(result.data).toBe(representation.data); + expect(result.metadata).toBe(representation.metadata); + expect(interactionHandler.handleSafe).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { body, ...partialOperation } = operation; + expect(interactionHandler.handleSafe).toHaveBeenLastCalledWith( + { operation: expect.objectContaining(partialOperation) }, + ); + expect(interactionHandler.handleSafe.mock.calls[0][0].operation.body.metadata.contentType).toBe(APPLICATION_JSON); - expect(JSON.parse(await readableToString(result.data!))).toEqual({ name: error.name, message: error.message }); - expect(result.statusCode).toBe(405); - expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe(viewTemplate); - - // Setup is not disabled since there was an error - expect(storage.get(storageKey)).toBeUndefined(); - }); - - it('errors when attempting registration when no RegistrationManager is defined.', async(): Promise => { - handler = new SetupHttpHandler({ - errorHandler, - initializer, - converter, - storageKey, - storage, - viewTemplate, - responseTemplate, - }); - operation.method = 'POST'; - const requestBody = { initialize: false, registration: true }; - operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json'); - const error = new NotImplementedHttpError('This server is not configured to support registration during setup.'); - await expect(testPost({ initialize: false, registration: true }, error)).resolves.toBeUndefined(); - - // Setup is not disabled since there was an error - expect(storage.get(storageKey)).toBeUndefined(); - }); - - it('errors when attempting initialization when no Initializer is defined.', async(): Promise => { - handler = new SetupHttpHandler({ - errorHandler, - registrationManager, - converter, - storageKey, - storage, - viewTemplate, - responseTemplate, - }); - operation.method = 'POST'; - const requestBody = { initialize: true, registration: false }; - operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json'); - const error = new NotImplementedHttpError('This server is not configured with a setup initializer.'); - await expect(testPost({ initialize: true, registration: false }, error)).resolves.toBeUndefined(); - - // Setup is not disabled since there was an error - expect(storage.get(storageKey)).toBeUndefined(); + // Handler is now disabled due to successful POST + expect(storage.get(storageKey)).toBe(true); }); }); From 693f441a1b2af1003a9e51270a0f527b119a4a29 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Thu, 9 Dec 2021 11:53:05 +0100 Subject: [PATCH 25/39] docs: Update RELEASE_NOTES with IDP architecture change --- RELEASE_NOTES.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 9a75fc02d..e6a208d7f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -29,11 +29,15 @@ The following changes are relevant for v2 custom configs that replaced certain f - Requests targeting the OIDC library now use a separate handler. - `/http/handler/default.json` - `/identity/handler/default.json` +- The architecture of IDP interaction handlers has completely changed to improve modularity + - `/identity/handler/interaction/*` + - `/identity/registration/*` ### Interface changes These changes are relevant if you wrote custom modules for the server that depend on existing interfaces. - `TypedRepresentationConverter` function signatures changed - and base functionality moved to `BaseTypedRepresentationConverter` + and base functionality moved to `BaseTypedRepresentationConverter`. +- Many changes to several components related to the IDP. This includes the HTML templates. ## v2.0.0 ### New features From ce754c119fb87dc8a4f79c639e316bd04d40109b Mon Sep 17 00:00:00 2001 From: Thomas Dupont Date: Tue, 15 Feb 2022 13:44:03 +0100 Subject: [PATCH 26/39] fix: Add content-negotiation when fetching dataset from url * Solution works but tests don't * refactor(FetchUtil): use arrayifyStream * refactor(FetchUtil): split fetchDataset into 2 separate functions * style(FetchUtil): onelining instead of declaring new local var * test: trying to mock rdfDereferencer * refactor: promise can't have async function as arg * test(FetchUtil): pass Quad array to mockDereference instead * test: all tests should pass now and coverage is back to 100% * style: comment typo * chore: make package.json and package-lock.json compatible with main * chore: fix package.json double entries * chore: updated package.json to be alfabetical again * refactor(AgentGroupAccessChecker): Remove converter from contructor and config * refactor(TokenOwnerShipValidator): Remove converter from constructor and config * refactor(FetchUtil): Return BadRequestHttpError instead of generic Error * test(FetchUtil): return Response object instead of mocking fetch * style: typos and newlines --- config/identity/ownership/token.json | 1 - .../readers/access-checkers/agent-group.json | 1 - package-lock.json | 1 + package.json | 1 + .../access/AgentGroupAccessChecker.ts | 8 +- .../ownership/TokenOwnershipValidator.ts | 7 +- src/identity/storage/WebIdAdapterFactory.ts | 4 +- src/util/FetchUtil.ts | 35 ++++--- .../access/AgentGroupAccessChecker.test.ts | 4 +- .../ownership/TokenOwnershipValidator.test.ts | 38 +++---- test/unit/util/FetchUtil.test.ts | 98 ++++++++++++------- 11 files changed, 113 insertions(+), 85 deletions(-) diff --git a/config/identity/ownership/token.json b/config/identity/ownership/token.json index fb3275c70..e5d9fdace 100644 --- a/config/identity/ownership/token.json +++ b/config/identity/ownership/token.json @@ -5,7 +5,6 @@ "comment": "Determines WebID ownership by requesting a specific value to be added to the WebID document", "@id": "urn:solid-server:auth:password:OwnershipValidator", "@type": "TokenOwnershipValidator", - "converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, "storage": { "@id": "urn:solid-server:default:ExpiringTokenStorage" } }, diff --git a/config/ldp/authorization/readers/access-checkers/agent-group.json b/config/ldp/authorization/readers/access-checkers/agent-group.json index fca974b8f..9cf614b1c 100644 --- a/config/ldp/authorization/readers/access-checkers/agent-group.json +++ b/config/ldp/authorization/readers/access-checkers/agent-group.json @@ -5,7 +5,6 @@ "comment": "Checks if the agent belongs to a group that has access.", "@id": "urn:solid-server:default:AgentGroupAccessChecker", "@type": "AgentGroupAccessChecker", - "converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, "cache": { "@id": "urn:solid-server:default:ExpiringAclCache", "@type": "WrappedExpiringStorage", diff --git a/package-lock.json b/package-lock.json index 6d9cc1450..8da5e3526 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "oidc-provider": "^6.31.1", "pump": "^3.0.0", "punycode": "^2.1.1", + "rdf-dereference": "^1.9.0", "rdf-parse": "^1.9.1", "rdf-serialize": "^1.2.0", "rdf-terms": "^1.7.1", diff --git a/package.json b/package.json index a00ff2b86..181ac00a9 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "oidc-provider": "^6.31.1", "pump": "^3.0.0", "punycode": "^2.1.1", + "rdf-dereference": "^1.9.0", "rdf-parse": "^1.9.1", "rdf-serialize": "^1.2.0", "rdf-terms": "^1.7.1", diff --git a/src/authorization/access/AgentGroupAccessChecker.ts b/src/authorization/access/AgentGroupAccessChecker.ts index b56cc537d..64b458efc 100644 --- a/src/authorization/access/AgentGroupAccessChecker.ts +++ b/src/authorization/access/AgentGroupAccessChecker.ts @@ -1,6 +1,5 @@ import type { Store, Term } from 'n3'; import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; -import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter'; import type { ExpiringStorage } from '../../storage/keyvalue/ExpiringStorage'; import { fetchDataset } from '../../util/FetchUtil'; import { promiseSome } from '../../util/PromiseUtil'; @@ -19,14 +18,11 @@ import { AccessChecker } from './AccessChecker'; * `expiration` parameter is how long entries in the cache should be stored in seconds, defaults to 3600. */ export class AgentGroupAccessChecker extends AccessChecker { - private readonly converter: RepresentationConverter; private readonly cache: ExpiringStorage>; private readonly expiration: number; - public constructor(converter: RepresentationConverter, cache: ExpiringStorage>, - expiration = 3600) { + public constructor(cache: ExpiringStorage>, expiration = 3600) { super(); - this.converter = converter; this.cache = cache; this.expiration = expiration * 1000; } @@ -65,7 +61,7 @@ export class AgentGroupAccessChecker extends AccessChecker { let result = await this.cache.get(url); if (!result) { const prom = (async(): Promise => { - const representation = await fetchDataset(url, this.converter); + const representation = await fetchDataset(url); return readableToQuads(representation.data); })(); await this.cache.set(url, prom, this.expiration); diff --git a/src/identity/ownership/TokenOwnershipValidator.ts b/src/identity/ownership/TokenOwnershipValidator.ts index 4ba0ca501..3f0504d83 100644 --- a/src/identity/ownership/TokenOwnershipValidator.ts +++ b/src/identity/ownership/TokenOwnershipValidator.ts @@ -2,7 +2,6 @@ import type { Quad } from 'n3'; import { DataFactory } from 'n3'; import { v4 } from 'uuid'; import { getLoggerFor } from '../../logging/LogUtil'; -import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter'; import type { ExpiringStorage } from '../../storage/keyvalue/ExpiringStorage'; import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; import { fetchDataset } from '../../util/FetchUtil'; @@ -17,13 +16,11 @@ const { literal, namedNode, quad } = DataFactory; export class TokenOwnershipValidator extends OwnershipValidator { protected readonly logger = getLoggerFor(this); - private readonly converter: RepresentationConverter; private readonly storage: ExpiringStorage; private readonly expiration: number; - public constructor(converter: RepresentationConverter, storage: ExpiringStorage, expiration = 30) { + public constructor(storage: ExpiringStorage, expiration = 30) { super(); - this.converter = converter; this.storage = storage; // Convert minutes to milliseconds this.expiration = expiration * 60 * 1000; @@ -66,7 +63,7 @@ export class TokenOwnershipValidator extends OwnershipValidator { * Fetches data from the WebID to determine if the token is present. */ private async hasToken(webId: string, token: string): Promise { - const representation = await fetchDataset(webId, this.converter); + const representation = await fetchDataset(webId); const expectedQuad = quad(namedNode(webId), SOLID.terms.oidcIssuerRegistrationToken, literal(token)); for await (const data of representation.data) { const triple = data as Quad; diff --git a/src/identity/storage/WebIdAdapterFactory.ts b/src/identity/storage/WebIdAdapterFactory.ts index c29c334aa..734d0126e 100644 --- a/src/identity/storage/WebIdAdapterFactory.ts +++ b/src/identity/storage/WebIdAdapterFactory.ts @@ -5,7 +5,7 @@ import type { Adapter, AdapterPayload } from 'oidc-provider'; import { getLoggerFor } from '../../logging/LogUtil'; import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter'; import { createErrorMessage } from '../../util/errors/ErrorUtil'; -import { fetchDataset } from '../../util/FetchUtil'; +import { responseToDataset } from '../../util/FetchUtil'; import { OIDC } from '../../util/Vocabularies'; import type { AdapterFactory } from './AdapterFactory'; @@ -91,7 +91,7 @@ export class WebIdAdapter implements Adapter { * @param response - Response object from the request. */ private async parseRdfWebId(data: string, id: string, response: Response): Promise { - const representation = await fetchDataset(response, this.converter, data); + const representation = await responseToDataset(response, this.converter, data); // Find the valid redirect URIs const redirectUris: string[] = []; diff --git a/src/util/FetchUtil.ts b/src/util/FetchUtil.ts index 9d1c317a3..5398dede5 100644 --- a/src/util/FetchUtil.ts +++ b/src/util/FetchUtil.ts @@ -1,5 +1,8 @@ +import type { Readable } from 'stream'; +import type { Quad } from '@rdfjs/types'; +import arrayifyStream from 'arrayify-stream'; import type { Response } from 'cross-fetch'; -import { fetch } from 'cross-fetch'; +import rdfDereferencer from 'rdf-dereference'; import { BasicRepresentation } from '../http/representation/BasicRepresentation'; import type { Representation } from '../http/representation/Representation'; import { getLoggerFor } from '../logging/LogUtil'; @@ -12,24 +15,32 @@ const logger = getLoggerFor('FetchUtil'); /** * Fetches an RDF dataset from the given URL. - * Input can also be a Response if the request was already made. + * + * Response will be a Representation with content-type internal/quads. + */ +export async function fetchDataset(url: string): Promise { + // Try content negotiation to parse quads from the URL + return (async(): Promise => { + try { + const quadStream = (await rdfDereferencer.dereference(url)).quads as Readable; + const quadArray = await arrayifyStream(quadStream) as Quad[]; + return new BasicRepresentation(quadArray, { path: url }, INTERNAL_QUADS, false); + } catch { + throw new BadRequestHttpError(`Could not parse resource at URL (${url})!`); + } + })(); +} + +/** + * Converts a given Response (from a request that was already made) to an RDF dataset. * In case the given Response object was already parsed its body can be passed along as a string. * * The converter will be used to convert the response body to RDF. * * Response will be a Representation with content-type internal/quads. */ -export async function fetchDataset(url: string, converter: RepresentationConverter): Promise; -export async function fetchDataset(response: Response, converter: RepresentationConverter, body?: string): -Promise; -export async function fetchDataset(input: string | Response, converter: RepresentationConverter, body?: string): +export async function responseToDataset(response: Response, converter: RepresentationConverter, body?: string): Promise { - let response: Response; - if (typeof input === 'string') { - response = await fetch(input); - } else { - response = input; - } if (!body) { body = await response.text(); } diff --git a/test/unit/authorization/access/AgentGroupAccessChecker.test.ts b/test/unit/authorization/access/AgentGroupAccessChecker.test.ts index ae86a9356..adc44727c 100644 --- a/test/unit/authorization/access/AgentGroupAccessChecker.test.ts +++ b/test/unit/authorization/access/AgentGroupAccessChecker.test.ts @@ -3,7 +3,6 @@ import type { AccessCheckerArgs } from '../../../../src/authorization/access/Acc import { AgentGroupAccessChecker } from '../../../../src/authorization/access/AgentGroupAccessChecker'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../../src/http/representation/Representation'; -import type { RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter'; import type { ExpiringStorage } from '../../../../src/storage/keyvalue/ExpiringStorage'; import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes'; import * as fetchUtil from '../../../../src/util/FetchUtil'; @@ -18,7 +17,6 @@ describe('An AgentGroupAccessChecker', (): void => { acl.addQuad(namedNode('noMatch'), ACL.terms.agentGroup, namedNode('badGroup')); let fetchMock: jest.SpyInstance; let representation: Representation; - const converter: RepresentationConverter = {} as any; let cache: ExpiringStorage>; let checker: AgentGroupAccessChecker; @@ -31,7 +29,7 @@ describe('An AgentGroupAccessChecker', (): void => { cache = new Map() as any; - checker = new AgentGroupAccessChecker(converter, cache); + checker = new AgentGroupAccessChecker(cache); }); it('can handle all requests.', async(): Promise => { diff --git a/test/unit/identity/ownership/TokenOwnershipValidator.test.ts b/test/unit/identity/ownership/TokenOwnershipValidator.test.ts index 78b327e15..46c58f0c3 100644 --- a/test/unit/identity/ownership/TokenOwnershipValidator.test.ts +++ b/test/unit/identity/ownership/TokenOwnershipValidator.test.ts @@ -1,15 +1,17 @@ -import { fetch } from 'cross-fetch'; +import { Readable } from 'stream'; import { DataFactory } from 'n3'; import type { Quad } from 'n3'; +import rdfDereferencer from 'rdf-dereference'; import { v4 } from 'uuid'; import { TokenOwnershipValidator } from '../../../../src/identity/ownership/TokenOwnershipValidator'; -import { RdfToQuadConverter } from '../../../../src/storage/conversion/RdfToQuadConverter'; import type { ExpiringStorage } from '../../../../src/storage/keyvalue/ExpiringStorage'; import { SOLID } from '../../../../src/util/Vocabularies'; const { literal, namedNode, quad } = DataFactory; -jest.mock('cross-fetch'); jest.mock('uuid'); +jest.mock('rdf-dereference', (): any => ({ + dereference: jest.fn(), +})); function quadToString(qq: Quad): string { const subPred = `<${qq.subject.value}> <${qq.predicate.value}>`; @@ -20,21 +22,19 @@ function quadToString(qq: Quad): string { } describe('A TokenOwnershipValidator', (): void => { - const fetchMock: jest.Mock = fetch as any; + const rdfDereferenceMock: jest.Mocked = rdfDereferencer as any; const webId = 'http://alice.test.com/#me'; const token = 'randomlyGeneratedToken'; const tokenTriple = quad(namedNode(webId), SOLID.terms.oidcIssuerRegistrationToken, literal(token)); const tokenString = `${quadToString(tokenTriple)}.`; - const converter = new RdfToQuadConverter(); let storage: ExpiringStorage; let validator: TokenOwnershipValidator; - function mockFetch(body: string): void { - fetchMock.mockImplementation((url: string): any => ({ - text: (): any => body, - url, - status: 200, - headers: { get: (): any => 'text/turtle' }, + function mockDereference(qq?: Quad): any { + rdfDereferenceMock.dereference.mockImplementation((uri: string): any => ({ + uri, + quads: Readable.from(qq ? [ qq ] : []), + exists: true, })); } @@ -50,32 +50,32 @@ describe('A TokenOwnershipValidator', (): void => { delete: jest.fn().mockImplementation((key: string): any => map.delete(key)), } as any; - mockFetch(''); + mockDereference(); - validator = new TokenOwnershipValidator(converter, storage); + validator = new TokenOwnershipValidator(storage); }); it('errors if no token is stored in the storage.', async(): Promise => { // Even if the token is in the WebId, it will error since it's not in the storage - mockFetch(tokenString); + mockDereference(tokenTriple); await expect(validator.handle({ webId })).rejects.toThrow(expect.objectContaining({ message: expect.stringContaining(tokenString), details: { quad: tokenString }, })); - expect(fetch).toHaveBeenCalledTimes(0); + expect(rdfDereferenceMock.dereference).toHaveBeenCalledTimes(0); }); it('errors if the expected triple is missing.', async(): Promise => { // First call will add the token to the storage await expect(validator.handle({ webId })).rejects.toThrow(tokenString); - expect(fetch).toHaveBeenCalledTimes(0); + expect(rdfDereferenceMock.dereference).toHaveBeenCalledTimes(0); // Second call will fetch the WebId await expect(validator.handle({ webId })).rejects.toThrow(tokenString); - expect(fetch).toHaveBeenCalledTimes(1); + expect(rdfDereferenceMock.dereference).toHaveBeenCalledTimes(1); }); it('resolves if the WebId contains the verification triple.', async(): Promise => { - mockFetch(tokenString); + mockDereference(tokenTriple); // First call will add the token to the storage await expect(validator.handle({ webId })).rejects.toThrow(tokenString); // Second call will succeed since it has the verification triple @@ -84,7 +84,7 @@ describe('A TokenOwnershipValidator', (): void => { it('fails if the WebId contains the wrong verification triple.', async(): Promise => { const wrongQuad = quad(namedNode(webId), SOLID.terms.oidcIssuerRegistrationToken, literal('wrongToken')); - mockFetch(`${quadToString(wrongQuad)} .`); + mockDereference(wrongQuad); // First call will add the token to the storage await expect(validator.handle({ webId })).rejects.toThrow(tokenString); // Second call will fail since it has the wrong verification triple diff --git a/test/unit/util/FetchUtil.test.ts b/test/unit/util/FetchUtil.test.ts index d08fa4c22..16e0d9782 100644 --- a/test/unit/util/FetchUtil.test.ts +++ b/test/unit/util/FetchUtil.test.ts @@ -1,55 +1,81 @@ +import { Readable } from 'stream'; +import type { Quad } from '@rdfjs/types'; import arrayifyStream from 'arrayify-stream'; -import { fetch } from 'cross-fetch'; +import type { Response } from 'cross-fetch'; import { DataFactory } from 'n3'; +import rdfDereferencer from 'rdf-dereference'; import { RdfToQuadConverter } from '../../../src/storage/conversion/RdfToQuadConverter'; -import { fetchDataset } from '../../../src/util/FetchUtil'; +import { fetchDataset, responseToDataset } from '../../../src/util/FetchUtil'; const { namedNode, quad } = DataFactory; -jest.mock('cross-fetch'); +jest.mock('rdf-dereference', (): any => ({ + dereference: jest.fn(), +})); describe('FetchUtil', (): void => { - describe('#fetchDataset', (): void => { - const fetchMock: jest.Mock = fetch as any; - const url = 'http://test.com/foo'; - const converter = new RdfToQuadConverter(); + const url = 'http://test.com/foo'; - function mockFetch(body: string, status = 200): void { - fetchMock.mockImplementation((input: string): any => ({ - text: (): any => body, - url: input, - status, - headers: { get: (): any => 'text/turtle' }, - })); + function mockResponse(body: string, contentType: string | null, status = 200): Response { + return ({ + text: (): any => body, + url, + status, + headers: { get: (): any => contentType }, + }) as any; + } + + describe('#fetchDataset', (): void => { + const rdfDereferenceMock: jest.Mocked = rdfDereferencer as any; + + function mockDereference(quads?: Quad[]): any { + rdfDereferenceMock.dereference.mockImplementation((uri: string): any => { + if (!quads) { + throw new Error('Throws error because url does not exist'); + } + return { + uri, + quads: Readable.from(quads), + exists: true, + }; + }); } - it('errors if the status code is not 200.', async(): Promise => { - mockFetch('Invalid URL!', 404); - await expect(fetchDataset(url, converter)).rejects.toThrow(`Unable to access data at ${url}`); - expect(fetchMock).toHaveBeenCalledWith(url); - }); - - it('errors if there is no content-type.', async(): Promise => { - fetchMock.mockResolvedValueOnce({ url, text: (): any => '', status: 200, headers: { get: jest.fn() }}); - await expect(fetchDataset(url, converter)).rejects.toThrow(`Unable to access data at ${url}`); - expect(fetchMock).toHaveBeenCalledWith(url); + it('errors if the URL does not exist.', async(): Promise => { + mockDereference(); + await expect(fetchDataset(url)).rejects.toThrow(`Could not parse resource at URL (${url})!`); + expect(rdfDereferenceMock.dereference).toHaveBeenCalledWith(url); }); it('returns a Representation with quads.', async(): Promise => { - mockFetch(' .'); - const representation = await fetchDataset(url, converter); - await expect(arrayifyStream(representation.data)).resolves.toEqual([ - quad(namedNode('http://test.com/s'), namedNode('http://test.com/p'), namedNode('http://test.com/o')), - ]); - }); - - it('accepts Response objects as input.', async(): Promise => { - mockFetch(' .'); - const response = await fetch(url); - const body = await response.text(); - const representation = await fetchDataset(response, converter, body); + const quads = [ quad(namedNode('http://test.com/s'), namedNode('http://test.com/p'), namedNode('http://test.com/o')) ]; + mockDereference(quads); + const representation = await fetchDataset(url); await expect(arrayifyStream(representation.data)).resolves.toEqual([ quad(namedNode('http://test.com/s'), namedNode('http://test.com/p'), namedNode('http://test.com/o')), ]); }); }); + + describe('#responseToDataset', (): void => { + const converter = new RdfToQuadConverter(); + + it('accepts Response objects as input.', async(): Promise => { + const response = mockResponse(' .', 'text/turtle'); + const body = await response.text(); + const representation = await responseToDataset(response, converter, body); + await expect(arrayifyStream(representation.data)).resolves.toEqual([ + quad(namedNode('http://test.com/s'), namedNode('http://test.com/p'), namedNode('http://test.com/o')), + ]); + }); + + it('errors if the status code is not 200.', async(): Promise => { + const response = mockResponse('Incorrect status!', null, 400); + await expect(responseToDataset(response, converter)).rejects.toThrow(`Unable to access data at ${url}`); + }); + + it('errors if there is no content-type.', async(): Promise => { + const response = mockResponse('No content-type!', null); + await expect(responseToDataset(response, converter)).rejects.toThrow(`Unable to access data at ${url}`); + }); + }); }); From 35e7e0d44756fa65d30e8ead0ddb9e95d459ee2d Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Mon, 14 Feb 2022 13:43:54 +0100 Subject: [PATCH 27/39] test: Add IDP test for clients with a WebID --- package-lock.json | 573 ++------------------------ package.json | 2 +- test/integration/Identity.test.ts | 77 ++++ test/integration/IdentityTestState.ts | 3 +- 4 files changed, 114 insertions(+), 541 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8da5e3526..b1e825606 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,7 +70,7 @@ "community-solid-server": "bin/server.js" }, "devDependencies": { - "@inrupt/solid-client-authn-node": "^1.11.3", + "@inrupt/solid-client-authn-node": "^1.11.5", "@microsoft/tsdoc-config": "^0.15.2", "@tsconfig/node12": "^1.0.9", "@types/cheerio": "^0.22.30", @@ -3728,9 +3728,9 @@ } }, "node_modules/@inrupt/solid-client-authn-core": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.11.3.tgz", - "integrity": "sha512-XhxlH+mmCbDQxRQVCQWR5tS/jM0S+lHyFkxDhT9Ts3gAokv4YfwgYsIe2jHgiY8T4Qp3keYFy4RVhZdSRgKGIQ==", + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.11.5.tgz", + "integrity": "sha512-hsGKgv2SsTEo33V5t9crRs+RdKgdtxuIb3VoiGmaDXqLkd9rI/CZZkqnpwUskf4VuBN7Z3h9TKAFohRJMcvF7Q==", "dev": true, "dependencies": { "@inrupt/solid-common-vocab": "^1.0.0", @@ -3744,24 +3744,24 @@ } }, "node_modules/@inrupt/solid-client-authn-node": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-node/-/solid-client-authn-node-1.11.3.tgz", - "integrity": "sha512-/4z6TcFOLbFwXPFK9P2TGeCVY19vMTDn1UBRiOJ4PA6arsTefWdtd1JMwSTkdB3BKg8bJJPEaYuA87odtixPCQ==", + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-node/-/solid-client-authn-node-1.11.5.tgz", + "integrity": "sha512-ToPEcnCL5BeQdfeB3MzhR9I+ZXQyuoGnumt60R57QarUi7de/BU2Ag+IAEvcbm+HvEtfF5LaabWbapryTRPGYA==", "dev": true, "dependencies": { - "@inrupt/solid-client-authn-core": "^1.11.3", - "@types/node": "^16.11.12", + "@inrupt/solid-client-authn-core": "^1.11.5", + "@types/node": "^17.0.2", "@types/uuid": "^8.3.0", "cross-fetch": "^3.0.6", "jose": "^4.3.7", - "openid-client": "^4.2.2", + "openid-client": "^5.1.0", "uuid": "^8.3.2" } }, "node_modules/@inrupt/solid-client-authn-node/node_modules/@types/node": { - "version": "16.11.22", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.22.tgz", - "integrity": "sha512-DYNtJWauMQ9RNpesl4aVothr97/tIJM8HbyOXJ0AYT1Z2bEjLHyfjOBPAQQVMLf8h3kSShYfNk8Wnto8B2zHUA==", + "version": "17.0.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.18.tgz", + "integrity": "sha512-eKj4f/BsN/qcculZiRSujogjvp5O/k4lOW5m35NopjZM/QwLOR075a8pJW5hD+Rtdm2DaCVPENS6KtSQnUD6BA==", "dev": true }, "node_modules/@inrupt/solid-common-vocab": { @@ -4341,18 +4341,6 @@ "@types/node": "*" } }, - "node_modules/@types/cacheable-request": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.1.tgz", - "integrity": "sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ==", - "dev": true, - "dependencies": { - "@types/http-cache-semantics": "*", - "@types/keyv": "*", - "@types/node": "*", - "@types/responselike": "*" - } - }, "node_modules/@types/cheerio": { "version": "0.22.30", "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.30.tgz", @@ -4455,12 +4443,6 @@ "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.1.tgz", "integrity": "sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ==" }, - "node_modules/@types/http-cache-semantics": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz", - "integrity": "sha512-c3Xy026kOF7QOTn00hbIllV1dLR9hG9NkSrLQgCVs8NF6sBU+VGWjD3wLPhmh1TYAc7ugCFsvHYMN4VcBN1U1A==", - "dev": true - }, "node_modules/@types/http-errors": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-1.8.0.tgz", @@ -4525,15 +4507,6 @@ "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz", "integrity": "sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==" }, - "node_modules/@types/keyv": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.1.tgz", - "integrity": "sha512-MPtoySlAZQ37VoLaPcTHCu1RWJ4llDkULYZIzOYxlhxBqYPB0RsRlmMU0R6tahtFe27mIdkHV+551ZWV4PLmVw==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/koa": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.13.1.tgz", @@ -4706,15 +4679,6 @@ "@types/bluebird": "*" } }, - "node_modules/@types/responselike": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", - "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/semver": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.6.tgz", @@ -5369,19 +5333,6 @@ "node": ">= 6.0.0" } }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -5947,15 +5898,6 @@ "node": ">= 6.0.0" } }, - "node_modules/cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true, - "engines": { - "node": ">=10.6.0" - } - }, "node_modules/cacheable-request": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", @@ -6234,15 +6176,6 @@ "node": ">=0.8.0" } }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/cli-boxes": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", @@ -9270,31 +9203,6 @@ "node": ">= 6" } }, - "node_modules/http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.0.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, - "node_modules/http2-wrapper/node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/https-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", @@ -12184,213 +12092,23 @@ } }, "node_modules/openid-client": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-4.7.4.tgz", - "integrity": "sha512-n+RURXYuR0bBZo9i0pn+CXZSyg5JYQ1nbwEwPQvLE7EcJt/vMZ2iIMjLehl5DvCN53XUoPVZs9KAE5r6d9fxsw==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.1.3.tgz", + "integrity": "sha512-i5quCXurPkN50ndRLE2D3Q6khz6AieJ0gTKOmsl3G4ZIP/Udf5Qw5CMRdhMvbFvfKRrkcCWPFXmduFUFYTC0xw==", "dev": true, "dependencies": { - "aggregate-error": "^3.1.0", - "got": "^11.8.0", - "jose": "^2.0.5", + "jose": "^4.1.4", "lru-cache": "^6.0.0", - "make-error": "^1.3.6", "object-hash": "^2.0.1", "oidc-token-hash": "^5.0.1" }, "engines": { - "node": "^10.19.0 || >=12.0.0 < 13 || >=13.7.0 < 14 || >= 14.2.0" + "node": "^12.19.0 || ^14.15.0 || ^16.13.0" }, "funding": { "url": "https://github.com/sponsors/panva" } }, - "node_modules/openid-client/node_modules/@sindresorhus/is": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.1.tgz", - "integrity": "sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/openid-client/node_modules/@szmarczak/http-timer": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.5.tgz", - "integrity": "sha512-PyRA9sm1Yayuj5OIoJ1hGt2YISX45w9WcFbh6ddT0Z/0yaFxOtGLInr4jUfU1EAFVs0Yfyfev4RNwBlUaHdlDQ==", - "dev": true, - "dependencies": { - "defer-to-connect": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/openid-client/node_modules/cacheable-request": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz", - "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==", - "dev": true, - "dependencies": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/openid-client/node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/openid-client/node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/openid-client/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/openid-client/node_modules/got": { - "version": "11.8.2", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.2.tgz", - "integrity": "sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==", - "dev": true, - "dependencies": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.1", - "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=10.19.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, - "node_modules/openid-client/node_modules/jose": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz", - "integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==", - "dev": true, - "dependencies": { - "@panva/asn1.js": "^1.0.0" - }, - "engines": { - "node": ">=10.13.0 < 13 || >=13.7.0" - }, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/openid-client/node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "node_modules/openid-client/node_modules/keyv": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.3.tgz", - "integrity": "sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA==", - "dev": true, - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/openid-client/node_modules/lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/openid-client/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/openid-client/node_modules/normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/openid-client/node_modules/p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/openid-client/node_modules/responselike": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz", - "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==", - "dev": true, - "dependencies": { - "lowercase-keys": "^2.0.0" - } - }, "node_modules/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -13329,12 +13047,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-alpn": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.1.2.tgz", - "integrity": "sha512-8OyfzhAtA32LVUsJSke3auIyINcwdh5l3cvYKdKO0nvsYSKuiLfTM5i78PJswFPT8y6cPW+L1v6/hE95chcpDA==", - "dev": true - }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -17993,9 +17705,9 @@ "dev": true }, "@inrupt/solid-client-authn-core": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.11.3.tgz", - "integrity": "sha512-XhxlH+mmCbDQxRQVCQWR5tS/jM0S+lHyFkxDhT9Ts3gAokv4YfwgYsIe2jHgiY8T4Qp3keYFy4RVhZdSRgKGIQ==", + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.11.5.tgz", + "integrity": "sha512-hsGKgv2SsTEo33V5t9crRs+RdKgdtxuIb3VoiGmaDXqLkd9rI/CZZkqnpwUskf4VuBN7Z3h9TKAFohRJMcvF7Q==", "dev": true, "requires": { "@inrupt/solid-common-vocab": "^1.0.0", @@ -18009,24 +17721,24 @@ } }, "@inrupt/solid-client-authn-node": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-node/-/solid-client-authn-node-1.11.3.tgz", - "integrity": "sha512-/4z6TcFOLbFwXPFK9P2TGeCVY19vMTDn1UBRiOJ4PA6arsTefWdtd1JMwSTkdB3BKg8bJJPEaYuA87odtixPCQ==", + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-node/-/solid-client-authn-node-1.11.5.tgz", + "integrity": "sha512-ToPEcnCL5BeQdfeB3MzhR9I+ZXQyuoGnumt60R57QarUi7de/BU2Ag+IAEvcbm+HvEtfF5LaabWbapryTRPGYA==", "dev": true, "requires": { - "@inrupt/solid-client-authn-core": "^1.11.3", - "@types/node": "^16.11.12", + "@inrupt/solid-client-authn-core": "^1.11.5", + "@types/node": "^17.0.2", "@types/uuid": "^8.3.0", "cross-fetch": "^3.0.6", "jose": "^4.3.7", - "openid-client": "^4.2.2", + "openid-client": "^5.1.0", "uuid": "^8.3.2" }, "dependencies": { "@types/node": { - "version": "16.11.22", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.22.tgz", - "integrity": "sha512-DYNtJWauMQ9RNpesl4aVothr97/tIJM8HbyOXJ0AYT1Z2bEjLHyfjOBPAQQVMLf8h3kSShYfNk8Wnto8B2zHUA==", + "version": "17.0.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.18.tgz", + "integrity": "sha512-eKj4f/BsN/qcculZiRSujogjvp5O/k4lOW5m35NopjZM/QwLOR075a8pJW5hD+Rtdm2DaCVPENS6KtSQnUD6BA==", "dev": true } } @@ -18520,18 +18232,6 @@ "@types/node": "*" } }, - "@types/cacheable-request": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.1.tgz", - "integrity": "sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ==", - "dev": true, - "requires": { - "@types/http-cache-semantics": "*", - "@types/keyv": "*", - "@types/node": "*", - "@types/responselike": "*" - } - }, "@types/cheerio": { "version": "0.22.30", "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.30.tgz", @@ -18634,12 +18334,6 @@ "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.1.tgz", "integrity": "sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ==" }, - "@types/http-cache-semantics": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz", - "integrity": "sha512-c3Xy026kOF7QOTn00hbIllV1dLR9hG9NkSrLQgCVs8NF6sBU+VGWjD3wLPhmh1TYAc7ugCFsvHYMN4VcBN1U1A==", - "dev": true - }, "@types/http-errors": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-1.8.0.tgz", @@ -18704,15 +18398,6 @@ "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz", "integrity": "sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==" }, - "@types/keyv": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.1.tgz", - "integrity": "sha512-MPtoySlAZQ37VoLaPcTHCu1RWJ4llDkULYZIzOYxlhxBqYPB0RsRlmMU0R6tahtFe27mIdkHV+551ZWV4PLmVw==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, "@types/koa": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.13.1.tgz", @@ -18884,15 +18569,6 @@ "@types/bluebird": "*" } }, - "@types/responselike": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", - "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, "@types/semver": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.6.tgz", @@ -19329,16 +19005,6 @@ "debug": "4" } }, - "aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "requires": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - } - }, "ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -19780,12 +19446,6 @@ "ylru": "^1.2.0" } }, - "cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true - }, "cacheable-request": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", @@ -19994,12 +19654,6 @@ } } }, - "clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true - }, "cli-boxes": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", @@ -22325,24 +21979,6 @@ "debug": "4" } }, - "http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, - "requires": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.0.0" - }, - "dependencies": { - "quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true - } - } - }, "https-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", @@ -24548,150 +24184,15 @@ "dev": true }, "openid-client": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-4.7.4.tgz", - "integrity": "sha512-n+RURXYuR0bBZo9i0pn+CXZSyg5JYQ1nbwEwPQvLE7EcJt/vMZ2iIMjLehl5DvCN53XUoPVZs9KAE5r6d9fxsw==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.1.3.tgz", + "integrity": "sha512-i5quCXurPkN50ndRLE2D3Q6khz6AieJ0gTKOmsl3G4ZIP/Udf5Qw5CMRdhMvbFvfKRrkcCWPFXmduFUFYTC0xw==", "dev": true, "requires": { - "aggregate-error": "^3.1.0", - "got": "^11.8.0", - "jose": "^2.0.5", + "jose": "^4.1.4", "lru-cache": "^6.0.0", - "make-error": "^1.3.6", "object-hash": "^2.0.1", "oidc-token-hash": "^5.0.1" - }, - "dependencies": { - "@sindresorhus/is": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.1.tgz", - "integrity": "sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g==", - "dev": true - }, - "@szmarczak/http-timer": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.5.tgz", - "integrity": "sha512-PyRA9sm1Yayuj5OIoJ1hGt2YISX45w9WcFbh6ddT0Z/0yaFxOtGLInr4jUfU1EAFVs0Yfyfev4RNwBlUaHdlDQ==", - "dev": true, - "requires": { - "defer-to-connect": "^2.0.0" - } - }, - "cacheable-request": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz", - "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==", - "dev": true, - "requires": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" - } - }, - "decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "requires": { - "mimic-response": "^3.1.0" - } - }, - "defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true - }, - "get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "requires": { - "pump": "^3.0.0" - } - }, - "got": { - "version": "11.8.2", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.2.tgz", - "integrity": "sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==", - "dev": true, - "requires": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.1", - "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" - } - }, - "jose": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz", - "integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==", - "dev": true, - "requires": { - "@panva/asn1.js": "^1.0.0" - } - }, - "json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "keyv": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.3.tgz", - "integrity": "sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA==", - "dev": true, - "requires": { - "json-buffer": "3.0.1" - } - }, - "lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true - }, - "mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true - }, - "normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true - }, - "p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true - }, - "responselike": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz", - "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==", - "dev": true, - "requires": { - "lowercase-keys": "^2.0.0" - } - } } }, "optionator": { @@ -25442,12 +24943,6 @@ "path-parse": "^1.0.6" } }, - "resolve-alpn": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.1.2.tgz", - "integrity": "sha512-8OyfzhAtA32LVUsJSke3auIyINcwdh5l3cvYKdKO0nvsYSKuiLfTM5i78PJswFPT8y6cPW+L1v6/hE95chcpDA==", - "dev": true - }, "resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", diff --git a/package.json b/package.json index 181ac00a9..1ddbc435d 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,7 @@ "yargs": "^17.3.1" }, "devDependencies": { - "@inrupt/solid-client-authn-node": "^1.11.3", + "@inrupt/solid-client-authn-node": "^1.11.5", "@microsoft/tsdoc-config": "^0.15.2", "@tsconfig/node12": "^1.0.9", "@types/cheerio": "^0.22.30", diff --git a/test/integration/Identity.test.ts b/test/integration/Identity.test.ts index a7c8ee2b0..3be14c145 100644 --- a/test/integration/Identity.test.ts +++ b/test/integration/Identity.test.ts @@ -176,6 +176,83 @@ describe('A Solid server with IDP', (): void => { }); }); + describe('authenticating a client with a WebID', (): void => { + const clientId = joinUrl(baseUrl, 'client-id'); + const badClientId = joinUrl(baseUrl, 'bad-client-id'); + /* eslint-disable @typescript-eslint/naming-convention */ + const clientJson = { + '@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld', + + client_id: clientId, + client_name: 'Solid Application Name', + redirect_uris: [ redirectUrl ], + post_logout_redirect_uris: [ 'https://app.example/logout' ], + client_uri: 'https://app.example/', + logo_uri: 'https://app.example/logo.png', + tos_uri: 'https://app.example/tos.html', + scope: 'openid profile offline_access webid', + grant_types: [ 'refresh_token', 'authorization_code' ], + response_types: [ 'code' ], + default_max_age: 3600, + require_auth_time: true, + }; + /* eslint-enable @typescript-eslint/naming-convention */ + let state: IdentityTestState; + + beforeAll(async(): Promise => { + state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer); + + await fetch(clientId, { + method: 'PUT', + headers: { 'content-type': 'application/ld+json' }, + body: JSON.stringify(clientJson), + }); + + // This client will always reject requests since there is no valid redirect + clientJson.client_id = badClientId; + clientJson.redirect_uris = []; + await fetch(badClientId, { + method: 'PUT', + headers: { 'content-type': 'application/ld+json' }, + body: JSON.stringify(clientJson), + }); + }); + + afterAll(async(): Promise => { + await state.session.logout(); + }); + + it('initializes the session and logs in.', async(): Promise => { + const url = await state.startSession(clientId); + const res = await state.fetchIdp(url); + expect(res.status).toBe(200); + await state.login(url, email, password); + expect(state.session.info?.webId).toBe(webId); + }); + + it('rejects requests in case the redirect URL is not accepted.', async(): Promise => { + // This test allows us to make sure the server actually uses the client WebID. + // If it did not, it would not see the invalid redirect_url array. + + let nextUrl = ''; + await state.session.login({ + redirectUrl, + oidcIssuer, + clientId: badClientId, + handleRedirect(data): void { + nextUrl = data; + }, + }); + expect(nextUrl.length > 0).toBeTruthy(); + expect(nextUrl.startsWith(oidcIssuer)).toBeTruthy(); + + // Redirect will error due to invalid client WebID + const res = await state.fetchIdp(nextUrl); + expect(res.status).toBe(400); + await expect(res.text()).resolves.toContain('invalid_redirect_uri'); + }); + }); + describe('resetting password', (): void => { let nextUrl: string; diff --git a/test/integration/IdentityTestState.ts b/test/integration/IdentityTestState.ts index 59816a44a..17f4364bb 100644 --- a/test/integration/IdentityTestState.ts +++ b/test/integration/IdentityTestState.ts @@ -74,11 +74,12 @@ export class IdentityTestState { * Initializes an authentication session and stores the relevant cookies for later re-use. * All te relevant links from the login page get extracted. */ - public async startSession(): Promise { + public async startSession(clientId?: string): Promise { let nextUrl = ''; await this.session.login({ redirectUrl: this.redirectUrl, oidcIssuer: this.oidcIssuer, + clientId, handleRedirect(data): void { nextUrl = data; }, From 1ed45c8903e8750b818885cb6e48183e4c36f22a Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Mon, 14 Feb 2022 11:45:10 +0100 Subject: [PATCH 28/39] fix: Prevent login page from showing error before redirect --- templates/scripts/util.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/templates/scripts/util.js b/templates/scripts/util.js index f0c8099be..2b07423b5 100644 --- a/templates/scripts/util.js +++ b/templates/scripts/util.js @@ -72,7 +72,9 @@ async function addPostListener(formId, errorId, apiTarget, callback) { try { const json = await postJsonForm(formId, apiTarget); - callback(json); + if (json) { + callback(json); + } } catch (error) { errorBlock.innerText = error.message; } From 1769b799df090a036f2d2925c06ba8d9f7130e6b Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Mon, 14 Feb 2022 12:02:03 +0100 Subject: [PATCH 29/39] fix: Make IDP routes independent of handlers --- .../identity/handler/interaction/routes.json | 16 ++++---- .../interaction/routes/existing-login.json | 13 +++++-- .../interaction/routes/forgot-password.json | 13 +++++-- .../handler/interaction/routes/index.json | 13 +++++-- .../handler/interaction/routes/login.json | 13 +++++-- .../handler/interaction/routes/prompt.json | 12 ++++-- .../interaction/routes/reset-password.json | 13 +++++-- config/identity/registration/enabled.json | 12 +----- .../registration/route/registration.json | 13 +++++-- .../routing/AbsolutePathInteractionRoute.ts | 16 ++++++++ ...ionRoute.ts => InteractionRouteHandler.ts} | 26 +++++-------- ...ute.ts => RelativePathInteractionRoute.ts} | 10 ++--- src/index.ts | 5 ++- .../AbsolutePathInteractionRoute.test.ts | 12 ++++++ ...est.ts => InteractionRouteHandler.test.ts} | 38 ++++++++----------- .../routing/RelativeInteractionRoute.test.ts | 30 --------------- .../RelativePathInteractionRoute.test.ts | 24 ++++++++++++ 17 files changed, 156 insertions(+), 123 deletions(-) create mode 100644 src/identity/interaction/routing/AbsolutePathInteractionRoute.ts rename src/identity/interaction/routing/{BasicInteractionRoute.ts => InteractionRouteHandler.ts} (51%) rename src/identity/interaction/routing/{RelativeInteractionRoute.ts => RelativePathInteractionRoute.ts} (59%) create mode 100644 test/unit/identity/interaction/routing/AbsolutePathInteractionRoute.test.ts rename test/unit/identity/interaction/routing/{BasicInteractionRoute.test.ts => InteractionRouteHandler.test.ts} (52%) delete mode 100644 test/unit/identity/interaction/routing/RelativeInteractionRoute.test.ts create mode 100644 test/unit/identity/interaction/routing/RelativePathInteractionRoute.test.ts diff --git a/config/identity/handler/interaction/routes.json b/config/identity/handler/interaction/routes.json index d1b92d5a6..07d40db70 100644 --- a/config/identity/handler/interaction/routes.json +++ b/config/identity/handler/interaction/routes.json @@ -30,11 +30,11 @@ "comment": "Converts redirect errors to location JSON responses.", "@id": "urn:solid-server:auth:password:LocationInteractionHandler", "@type": "LocationInteractionHandler", - "LocationInteractionHandler:_source" : { "@id": "urn:solid-server:auth:password:RouteInteractionHandler" } + "LocationInteractionHandler:_source" : { "@id": "urn:solid-server:auth:password:InteractionRouteHandler" } }, { "comment": "Handles every interaction based on their route.", - "@id": "urn:solid-server:auth:password:RouteInteractionHandler", + "@id": "urn:solid-server:auth:password:InteractionRouteHandler", "@type": "WaterfallHandler", "handlers": [ { @@ -44,12 +44,12 @@ ], "@type": "UnsupportedAsyncHandler" }, - { "@id": "urn:solid-server:auth:password:IndexRoute" }, - { "@id": "urn:solid-server:auth:password:PromptRoute" }, - { "@id": "urn:solid-server:auth:password:LoginRoute" }, - { "@id": "urn:solid-server:auth:password:ExistingLoginRoute" }, - { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" }, - { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" } + { "@id": "urn:solid-server:auth:password:IndexRouteHandler" }, + { "@id": "urn:solid-server:auth:password:PromptRouteHandler" }, + { "@id": "urn:solid-server:auth:password:LoginRouteHandler" }, + { "@id": "urn:solid-server:auth:password:ExistingLoginRouteHandler" }, + { "@id": "urn:solid-server:auth:password:ForgotPasswordRouteHandler" }, + { "@id": "urn:solid-server:auth:password:ResetPasswordRouteHandler" } ] } ] diff --git a/config/identity/handler/interaction/routes/existing-login.json b/config/identity/handler/interaction/routes/existing-login.json index 17ad7da19..915373ff0 100644 --- a/config/identity/handler/interaction/routes/existing-login.json +++ b/config/identity/handler/interaction/routes/existing-login.json @@ -3,11 +3,16 @@ "@graph": [ { "comment": "Handles the interaction that occurs when a logged in user wants to authenticate with a new app.", - "@id": "urn:solid-server:auth:password:ExistingLoginRoute", - "@type": "RelativeInteractionRoute", - "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, - "relativePath": "/consent/", + "@id": "urn:solid-server:auth:password:ExistingLoginRouteHandler", + "@type":"InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:auth:password:ExistingLoginRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/consent/" + }, "source": { + "@id": "urn:solid-server:auth:password:ExistingLoginHandler", "@type": "ExistingLoginHandler", "interactionCompleter": { "@type": "BaseInteractionCompleter" } } diff --git a/config/identity/handler/interaction/routes/forgot-password.json b/config/identity/handler/interaction/routes/forgot-password.json index b3e32da5b..748d58a0a 100644 --- a/config/identity/handler/interaction/routes/forgot-password.json +++ b/config/identity/handler/interaction/routes/forgot-password.json @@ -3,11 +3,16 @@ "@graph": [ { "comment": "Handles the forgot password interaction", - "@id": "urn:solid-server:auth:password:ForgotPasswordRoute", - "@type": "RelativeInteractionRoute", - "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, - "relativePath": "/forgotpassword/", + "@id": "urn:solid-server:auth:password:ForgotPasswordRouteHandler", + "@type":"InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:auth:password:ForgotPasswordRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/forgotpassword/" + }, "source": { + "@id": "urn:solid-server:auth:password:ForgotPasswordHandler", "@type": "ForgotPasswordHandler", "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, "args_templateEngine": { diff --git a/config/identity/handler/interaction/routes/index.json b/config/identity/handler/interaction/routes/index.json index 419edd709..145b4fcf2 100644 --- a/config/identity/handler/interaction/routes/index.json +++ b/config/identity/handler/interaction/routes/index.json @@ -3,11 +3,16 @@ "@graph": [ { "comment": "Root API entry. Returns an empty body so we can add controls pointing to other interaction routes.", - "@id": "urn:solid-server:auth:password:IndexRoute", - "@type": "RelativeInteractionRoute", - "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "relativePath": "/idp/", + "@id": "urn:solid-server:auth:password:IndexRouteHandler", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:auth:password:IndexRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "relativePath": "/idp/" + }, "source": { + "@id": "urn:solid-server:auth:password:IndexHandler", "@type": "FixedInteractionHandler", "response": {} } diff --git a/config/identity/handler/interaction/routes/login.json b/config/identity/handler/interaction/routes/login.json index 294af8e49..b3bf1b108 100644 --- a/config/identity/handler/interaction/routes/login.json +++ b/config/identity/handler/interaction/routes/login.json @@ -3,11 +3,16 @@ "@graph": [ { "comment": "Handles the login interaction", - "@id": "urn:solid-server:auth:password:LoginRoute", - "@type": "RelativeInteractionRoute", - "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, - "relativePath": "/login/", + "@id": "urn:solid-server:auth:password:LoginRouteHandler", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:auth:password:LoginRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/login/" + }, "source": { + "@id": "urn:solid-server:auth:password:LoginHandler", "@type": "LoginHandler", "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, "interactionCompleter": { "@type": "BaseInteractionCompleter" } diff --git a/config/identity/handler/interaction/routes/prompt.json b/config/identity/handler/interaction/routes/prompt.json index 0dba93633..98cecae96 100644 --- a/config/identity/handler/interaction/routes/prompt.json +++ b/config/identity/handler/interaction/routes/prompt.json @@ -3,10 +3,14 @@ "@graph": [ { "comment": "Handles OIDC redirects containing a prompt, such as login or consent.", - "@id": "urn:solid-server:auth:password:PromptRoute", - "@type": "RelativeInteractionRoute", - "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, - "relativePath": "/prompt/", + "@id": "urn:solid-server:auth:password:PromptRouteHandler", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:auth:password:PromptRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/prompt/" + }, "source": { "@type": "PromptHandler", "@id": "urn:solid-server:auth:password:PromptHandler", diff --git a/config/identity/handler/interaction/routes/reset-password.json b/config/identity/handler/interaction/routes/reset-password.json index 5c1bc3b92..ef6aa9335 100644 --- a/config/identity/handler/interaction/routes/reset-password.json +++ b/config/identity/handler/interaction/routes/reset-password.json @@ -3,11 +3,16 @@ "@graph": [ { "comment": "Handles the reset password interaction", - "@id": "urn:solid-server:auth:password:ResetPasswordRoute", - "@type": "RelativeInteractionRoute", - "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, - "relativePath": "/resetpassword/", + "@id": "urn:solid-server:auth:password:ResetPasswordRouteHandler", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:auth:password:ResetPasswordRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/resetpassword/" + }, "source": { + "@id": "urn:solid-server:auth:password:ResetPasswordHandler", "@type": "ResetPasswordHandler", "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" } } diff --git a/config/identity/registration/enabled.json b/config/identity/registration/enabled.json index db6579f17..5caedc4f4 100644 --- a/config/identity/registration/enabled.json +++ b/config/identity/registration/enabled.json @@ -5,7 +5,7 @@ ], "@graph": [ { - "@id": "urn:solid-server:auth:password:RouteInteractionHandler", + "@id": "urn:solid-server:auth:password:InteractionRouteHandler", "WaterfallHandler:_handlers": [ { "comment": [ @@ -14,7 +14,7 @@ ], "@type": "UnsupportedAsyncHandler" }, - { "@id": "urn:solid-server:auth:password:RegistrationRoute" } + { "@id": "urn:solid-server:auth:password:RegistrationRouteHandler" } ] }, { @@ -32,14 +32,6 @@ { "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/register.html.ejs", "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:RegistrationRoute" } - }, - { - "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/reset-password-response.html.ejs", - "HtmlViewHandler:_templates_value": { - "@type": "RelativeInteractionRoute", - "base": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" }, - "relativePath": "/response/" - } } ] } diff --git a/config/identity/registration/route/registration.json b/config/identity/registration/route/registration.json index 9e2e684c1..693c9ba3e 100644 --- a/config/identity/registration/route/registration.json +++ b/config/identity/registration/route/registration.json @@ -3,11 +3,16 @@ "@graph": [ { "comment": "Handles the register interaction", - "@id": "urn:solid-server:auth:password:RegistrationRoute", - "@type": "RelativeInteractionRoute", - "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, - "relativePath": "/register/", + "@id": "urn:solid-server:auth:password:RegistrationRouteHandler", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:auth:password:RegistrationRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, + "relativePath": "/register/" + }, "source": { + "@id": "urn:solid-server:auth:password:RegistrationHandler", "@type": "RegistrationHandler", "registrationManager": { "@type": "RegistrationManager", diff --git a/src/identity/interaction/routing/AbsolutePathInteractionRoute.ts b/src/identity/interaction/routing/AbsolutePathInteractionRoute.ts new file mode 100644 index 000000000..7a7e0d897 --- /dev/null +++ b/src/identity/interaction/routing/AbsolutePathInteractionRoute.ts @@ -0,0 +1,16 @@ +import type { InteractionRoute } from './InteractionRoute'; + +/** + * A route that returns the input string as path. + */ +export class AbsolutePathInteractionRoute implements InteractionRoute { + private readonly path: string; + + public constructor(path: string) { + this.path = path; + } + + public getPath(): string { + return this.path; + } +} diff --git a/src/identity/interaction/routing/BasicInteractionRoute.ts b/src/identity/interaction/routing/InteractionRouteHandler.ts similarity index 51% rename from src/identity/interaction/routing/BasicInteractionRoute.ts rename to src/identity/interaction/routing/InteractionRouteHandler.ts index f726e687e..22573524d 100644 --- a/src/identity/interaction/routing/BasicInteractionRoute.ts +++ b/src/identity/interaction/routing/InteractionRouteHandler.ts @@ -1,36 +1,28 @@ import type { Representation } from '../../../http/representation/Representation'; import { NotFoundHttpError } from '../../../util/errors/NotFoundHttpError'; -import { UnsupportedAsyncHandler } from '../../../util/handlers/UnsupportedAsyncHandler'; -import { InteractionHandler } from '../InteractionHandler'; import type { InteractionHandlerInput } from '../InteractionHandler'; +import { InteractionHandler } from '../InteractionHandler'; import type { InteractionRoute } from './InteractionRoute'; /** - * Default implementation of an InteractionHandler with an InteractionRoute. + * InteractionHandler that only accepts operations with an expected path. * * Rejects operations that target a different path, - * otherwise the input parameters get passed to the source handler. - * - * In case no source handler is provided it defaults to an {@link UnsupportedAsyncHandler}. - * This can be useful if you want an object with just the route. + * otherwise the input parameters are passed to the source handler. */ -export class BasicInteractionRoute extends InteractionHandler implements InteractionRoute { - private readonly path: string; +export class InteractionRouteHandler extends InteractionHandler { + private readonly route: InteractionRoute; private readonly source: InteractionHandler; - public constructor(path: string, source?: InteractionHandler) { + public constructor(route: InteractionRoute, source: InteractionHandler) { super(); - this.path = path; - this.source = source ?? new UnsupportedAsyncHandler('This route has no associated handler.'); - } - - public getPath(): string { - return this.path; + this.route = route; + this.source = source; } public async canHandle(input: InteractionHandlerInput): Promise { const { target } = input.operation; - const path = this.getPath(); + const path = this.route.getPath(); if (target.path !== path) { throw new NotFoundHttpError(); } diff --git a/src/identity/interaction/routing/RelativeInteractionRoute.ts b/src/identity/interaction/routing/RelativePathInteractionRoute.ts similarity index 59% rename from src/identity/interaction/routing/RelativeInteractionRoute.ts rename to src/identity/interaction/routing/RelativePathInteractionRoute.ts index 6443b5ec3..70e7d0e93 100644 --- a/src/identity/interaction/routing/RelativeInteractionRoute.ts +++ b/src/identity/interaction/routing/RelativePathInteractionRoute.ts @@ -1,18 +1,16 @@ import { joinUrl } from '../../../util/PathUtil'; -import type { InteractionHandler } from '../InteractionHandler'; -import { BasicInteractionRoute } from './BasicInteractionRoute'; +import { AbsolutePathInteractionRoute } from './AbsolutePathInteractionRoute'; import type { InteractionRoute } from './InteractionRoute'; /** * A route that is relative to another route. * The relative path will be joined to the input base, * which can either be an absolute URL or an InteractionRoute of which the path will be used. - * The source handler will be called for all operation requests */ -export class RelativeInteractionRoute extends BasicInteractionRoute { - public constructor(base: InteractionRoute | string, relativePath: string, source?: InteractionHandler) { +export class RelativePathInteractionRoute extends AbsolutePathInteractionRoute { + public constructor(base: InteractionRoute | string, relativePath: string) { const url = typeof base === 'string' ? base : base.getPath(); const path = joinUrl(url, relativePath); - super(path, source); + super(path); } } diff --git a/src/index.ts b/src/index.ts index 2d68312c0..12dd3b8e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -145,9 +145,10 @@ export * from './identity/interaction/email-password/util/RegistrationManager'; export * from './identity/interaction/email-password/EmailPasswordUtil'; // Identity/Interaction/Routing -export * from './identity/interaction/routing/BasicInteractionRoute'; +export * from './identity/interaction/routing/AbsolutePathInteractionRoute'; export * from './identity/interaction/routing/InteractionRoute'; -export * from './identity/interaction/routing/RelativeInteractionRoute'; +export * from './identity/interaction/routing/InteractionRouteHandler'; +export * from './identity/interaction/routing/RelativePathInteractionRoute'; // Identity/Interaction/Util export * from './identity/interaction/util/BaseEmailSender'; diff --git a/test/unit/identity/interaction/routing/AbsolutePathInteractionRoute.test.ts b/test/unit/identity/interaction/routing/AbsolutePathInteractionRoute.test.ts new file mode 100644 index 000000000..bed58c07e --- /dev/null +++ b/test/unit/identity/interaction/routing/AbsolutePathInteractionRoute.test.ts @@ -0,0 +1,12 @@ +import { + AbsolutePathInteractionRoute, +} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute'; + +describe('An AbsolutePathInteractionRoute', (): void => { + const path = 'http://example.com/idp/path/'; + const route = new AbsolutePathInteractionRoute(path); + + it('returns the given path.', async(): Promise => { + expect(route.getPath()).toBe('http://example.com/idp/path/'); + }); +}); diff --git a/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts b/test/unit/identity/interaction/routing/InteractionRouteHandler.test.ts similarity index 52% rename from test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts rename to test/unit/identity/interaction/routing/InteractionRouteHandler.test.ts index 7ca15e559..cecc36cd9 100644 --- a/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts +++ b/test/unit/identity/interaction/routing/InteractionRouteHandler.test.ts @@ -1,59 +1,53 @@ import type { Operation } from '../../../../../src/http/Operation'; import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../../../src/http/representation/Representation'; -import type { - InteractionHandler, -} from '../../../../../src/identity/interaction/InteractionHandler'; -import { BasicInteractionRoute } from '../../../../../src/identity/interaction/routing/BasicInteractionRoute'; +import type { InteractionHandler } from '../../../../../src/identity/interaction/InteractionHandler'; +import type { InteractionRoute } from '../../../../../src/identity/interaction/routing/InteractionRoute'; +import { InteractionRouteHandler } from '../../../../../src/identity/interaction/routing/InteractionRouteHandler'; import { APPLICATION_JSON } from '../../../../../src/util/ContentTypes'; import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; import { createPostJsonOperation } from '../email-password/handler/Util'; -describe('A BasicInteractionRoute', (): void => { +describe('An InteractionRouteHandler', (): void => { const path = 'http://example.com/idp/path/'; let operation: Operation; let representation: Representation; + let route: InteractionRoute; let source: jest.Mocked; - let route: BasicInteractionRoute; + let handler: InteractionRouteHandler; beforeEach(async(): Promise => { - operation = createPostJsonOperation({}, 'http://example.com/idp/path/'); + operation = createPostJsonOperation({}, path); representation = new BasicRepresentation(JSON.stringify({}), APPLICATION_JSON); + route = { + getPath: jest.fn().mockReturnValue(path), + }; + source = { canHandle: jest.fn(), handle: jest.fn().mockResolvedValue(representation), } as any; - route = new BasicInteractionRoute(path, source); - }); - - it('returns the given path.', async(): Promise => { - expect(route.getPath()).toBe('http://example.com/idp/path/'); + handler = new InteractionRouteHandler(route, source); }); it('rejects other paths.', async(): Promise => { operation = createPostJsonOperation({}, 'http://example.com/idp/otherPath/'); - await expect(route.canHandle({ operation })).rejects.toThrow(NotFoundHttpError); + await expect(handler.canHandle({ operation })).rejects.toThrow(NotFoundHttpError); }); it('rejects input its source cannot handle.', async(): Promise => { source.canHandle.mockRejectedValueOnce(new Error('bad data')); - await expect(route.canHandle({ operation })).rejects.toThrow('bad data'); + await expect(handler.canHandle({ operation })).rejects.toThrow('bad data'); }); it('can handle requests its source can handle.', async(): Promise => { - await expect(route.canHandle({ operation })).resolves.toBeUndefined(); + await expect(handler.canHandle({ operation })).resolves.toBeUndefined(); }); it('lets its source handle requests.', async(): Promise => { - await expect(route.handle({ operation })).resolves.toBe(representation); - }); - - it('defaults to an UnsupportedAsyncHandler if no source is provided.', async(): Promise => { - route = new BasicInteractionRoute(path); - await expect(route.canHandle({ operation })).rejects.toThrow('This route has no associated handler.'); - await expect(route.handle({ operation })).rejects.toThrow('This route has no associated handler.'); + await expect(handler.handle({ operation })).resolves.toBe(representation); }); }); diff --git a/test/unit/identity/interaction/routing/RelativeInteractionRoute.test.ts b/test/unit/identity/interaction/routing/RelativeInteractionRoute.test.ts deleted file mode 100644 index 9d8bb8ba8..000000000 --- a/test/unit/identity/interaction/routing/RelativeInteractionRoute.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { - InteractionHandler, -} from '../../../../../src/identity/interaction/InteractionHandler'; -import type { InteractionRoute } from '../../../../../src/identity/interaction/routing/InteractionRoute'; -import { RelativeInteractionRoute } from '../../../../../src/identity/interaction/routing/RelativeInteractionRoute'; - -describe('A RelativeInteractionRoute', (): void => { - const relativePath = '/relative/'; - let route: jest.Mocked; - let source: jest.Mocked; - let relativeRoute: RelativeInteractionRoute; - - beforeEach(async(): Promise => { - route = { - getPath: jest.fn().mockReturnValue('http://example.com/'), - } as any; - - source = { - canHandle: jest.fn(), - } as any; - }); - - it('returns the joined path.', async(): Promise => { - relativeRoute = new RelativeInteractionRoute(route, relativePath, source); - expect(relativeRoute.getPath()).toBe('http://example.com/relative/'); - - relativeRoute = new RelativeInteractionRoute('http://example.com/', relativePath, source); - expect(relativeRoute.getPath()).toBe('http://example.com/relative/'); - }); -}); diff --git a/test/unit/identity/interaction/routing/RelativePathInteractionRoute.test.ts b/test/unit/identity/interaction/routing/RelativePathInteractionRoute.test.ts new file mode 100644 index 000000000..b8991202c --- /dev/null +++ b/test/unit/identity/interaction/routing/RelativePathInteractionRoute.test.ts @@ -0,0 +1,24 @@ +import type { InteractionRoute } from '../../../../../src/identity/interaction/routing/InteractionRoute'; +import { + RelativePathInteractionRoute, +} from '../../../../../src/identity/interaction/routing/RelativePathInteractionRoute'; + +describe('A RelativePathInteractionRoute', (): void => { + const relativePath = '/relative/'; + let route: jest.Mocked; + let relativeRoute: RelativePathInteractionRoute; + + beforeEach(async(): Promise => { + route = { + getPath: jest.fn().mockReturnValue('http://example.com/'), + }; + }); + + it('returns the joined path.', async(): Promise => { + relativeRoute = new RelativePathInteractionRoute(route, relativePath); + expect(relativeRoute.getPath()).toBe('http://example.com/relative/'); + + relativeRoute = new RelativePathInteractionRoute('http://example.com/test/', relativePath); + expect(relativeRoute.getPath()).toBe('http://example.com/test/relative/'); + }); +}); From c9ed90aeebaabca957ae1980738f732e5472ee9d Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 15 Feb 2022 16:58:36 +0100 Subject: [PATCH 30/39] fix: Update OIDC provider dependency to v7 The biggest resulting change is that the consent page always appears after logging in. Some minor fixes to be closer to the spec are included together with some minor structural refactors. --- .../identity/handler/interaction/routes.json | 4 +- .../{existing-login.json => consent.json} | 10 +- .../handler/interaction/routes/login.json | 3 +- .../handler/interaction/routes/prompt.json | 2 +- .../handler/interaction/views/html.json | 2 +- .../handler/provider-factory/identity.json | 16 +- package-lock.json | 894 +++++++++++++----- package.json | 3 +- src/identity/OidcHttpHandler.ts | 4 +- .../configuration/IdentityProviderFactory.ts | 74 +- .../CompletingInteractionHandler.ts | 48 - src/identity/interaction/ConsentHandler.ts | 111 +++ .../interaction/ExistingLoginHandler.ts | 25 - .../handler/ForgotPasswordHandler.ts | 2 +- .../email-password/handler/LoginHandler.ts | 35 +- .../util/BaseEmailSender.ts | 0 .../{ => email-password}/util/EmailSender.ts | 2 +- .../util/BaseInteractionCompleter.ts | 37 - .../interaction/util/InteractionCompleter.ts | 16 - src/index.ts | 11 +- test/integration/Identity.test.ts | 26 +- test/integration/IdentityTestState.ts | 24 +- test/integration/RestrictedIdentity.test.ts | 5 +- test/unit/identity/OidcHttpHandler.test.ts | 5 +- .../IdentityProviderFactory.test.ts | 31 +- .../CompletingInteractionHandler.test.ts | 76 -- .../interaction/ConsentHandler.test.ts | 142 +++ .../interaction/ExistingLoginHandler.test.ts | 38 - .../handler/ForgotPasswordHandler.test.ts | 2 +- .../handler/LoginHandler.test.ts | 32 +- .../util/BaseEmailSender.test.ts | 6 +- .../util/BaseInteractionCompleter.test.ts | 56 -- 32 files changed, 1081 insertions(+), 661 deletions(-) rename config/identity/handler/interaction/routes/{existing-login.json => consent.json} (61%) delete mode 100644 src/identity/interaction/CompletingInteractionHandler.ts create mode 100644 src/identity/interaction/ConsentHandler.ts delete mode 100644 src/identity/interaction/ExistingLoginHandler.ts rename src/identity/interaction/{ => email-password}/util/BaseEmailSender.ts (100%) rename src/identity/interaction/{ => email-password}/util/EmailSender.ts (75%) delete mode 100644 src/identity/interaction/util/BaseInteractionCompleter.ts delete mode 100644 src/identity/interaction/util/InteractionCompleter.ts delete mode 100644 test/unit/identity/interaction/CompletingInteractionHandler.test.ts create mode 100644 test/unit/identity/interaction/ConsentHandler.test.ts delete mode 100644 test/unit/identity/interaction/ExistingLoginHandler.test.ts rename test/unit/identity/interaction/{ => email-password}/util/BaseEmailSender.test.ts (82%) delete mode 100644 test/unit/identity/interaction/util/BaseInteractionCompleter.test.ts diff --git a/config/identity/handler/interaction/routes.json b/config/identity/handler/interaction/routes.json index 07d40db70..a15a177f9 100644 --- a/config/identity/handler/interaction/routes.json +++ b/config/identity/handler/interaction/routes.json @@ -1,7 +1,7 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", "import": [ - "files-scs:config/identity/handler/interaction/routes/existing-login.json", + "files-scs:config/identity/handler/interaction/routes/consent.json", "files-scs:config/identity/handler/interaction/routes/forgot-password.json", "files-scs:config/identity/handler/interaction/routes/index.json", "files-scs:config/identity/handler/interaction/routes/login.json", @@ -47,7 +47,7 @@ { "@id": "urn:solid-server:auth:password:IndexRouteHandler" }, { "@id": "urn:solid-server:auth:password:PromptRouteHandler" }, { "@id": "urn:solid-server:auth:password:LoginRouteHandler" }, - { "@id": "urn:solid-server:auth:password:ExistingLoginRouteHandler" }, + { "@id": "urn:solid-server:auth:password:ConsentRouteHandler" }, { "@id": "urn:solid-server:auth:password:ForgotPasswordRouteHandler" }, { "@id": "urn:solid-server:auth:password:ResetPasswordRouteHandler" } ] diff --git a/config/identity/handler/interaction/routes/existing-login.json b/config/identity/handler/interaction/routes/consent.json similarity index 61% rename from config/identity/handler/interaction/routes/existing-login.json rename to config/identity/handler/interaction/routes/consent.json index 915373ff0..e2fcfb60a 100644 --- a/config/identity/handler/interaction/routes/existing-login.json +++ b/config/identity/handler/interaction/routes/consent.json @@ -3,18 +3,18 @@ "@graph": [ { "comment": "Handles the interaction that occurs when a logged in user wants to authenticate with a new app.", - "@id": "urn:solid-server:auth:password:ExistingLoginRouteHandler", + "@id": "urn:solid-server:auth:password:ConsentRouteHandler", "@type":"InteractionRouteHandler", "route": { - "@id": "urn:solid-server:auth:password:ExistingLoginRoute", + "@id": "urn:solid-server:auth:password:ConsentRoute", "@type": "RelativePathInteractionRoute", "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, "relativePath": "/consent/" }, "source": { - "@id": "urn:solid-server:auth:password:ExistingLoginHandler", - "@type": "ExistingLoginHandler", - "interactionCompleter": { "@type": "BaseInteractionCompleter" } + "@id": "urn:solid-server:auth:password:ConsentHandler", + "@type": "ConsentHandler", + "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" } } } ] diff --git a/config/identity/handler/interaction/routes/login.json b/config/identity/handler/interaction/routes/login.json index b3bf1b108..ecab17bda 100644 --- a/config/identity/handler/interaction/routes/login.json +++ b/config/identity/handler/interaction/routes/login.json @@ -14,8 +14,7 @@ "source": { "@id": "urn:solid-server:auth:password:LoginHandler", "@type": "LoginHandler", - "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, - "interactionCompleter": { "@type": "BaseInteractionCompleter" } + "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" } } } ] diff --git a/config/identity/handler/interaction/routes/prompt.json b/config/identity/handler/interaction/routes/prompt.json index 98cecae96..cea3ce87e 100644 --- a/config/identity/handler/interaction/routes/prompt.json +++ b/config/identity/handler/interaction/routes/prompt.json @@ -21,7 +21,7 @@ }, { "PromptHandler:_promptRoutes_key": "consent", - "PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:auth:password:ExistingLoginRoute" } + "PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:auth:password:ConsentRoute" } } ] } diff --git a/config/identity/handler/interaction/views/html.json b/config/identity/handler/interaction/views/html.json index ba6e568c9..d70f0170c 100644 --- a/config/identity/handler/interaction/views/html.json +++ b/config/identity/handler/interaction/views/html.json @@ -28,7 +28,7 @@ }, { "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/consent.html.ejs", - "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ExistingLoginRoute" } + "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ConsentRoute" } }, { "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/forgot-password.html.ejs", diff --git a/config/identity/handler/provider-factory/identity.json b/config/identity/handler/provider-factory/identity.json index 7efe13956..ed9c505f2 100644 --- a/config/identity/handler/provider-factory/identity.json +++ b/config/identity/handler/provider-factory/identity.json @@ -17,7 +17,7 @@ "args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }, "config": { "claims": { - "openid": [ "client_id" ], + "openid": [ "azp" ], "webid": [ "webid" ] }, "cookies": { @@ -27,22 +27,24 @@ "features": { "claimsParameter": { "enabled": true }, "devInteractions": { "enabled": false }, - "dPoP": { "enabled": true, "ack": "draft-01" }, + "dPoP": { "enabled": true, "ack": "draft-03" }, "introspection": { "enabled": true }, "registration": { "enabled": true }, - "revocation": { "enabled": true } - }, - "formats": { - "AccessToken": "jwt" + "revocation": { "enabled": true }, + "userinfo": { "enabled": false } }, "scopes": [ "openid", "profile", "offline_access", "webid" ], "subjectTypes": [ "public" ], "ttl": { "AccessToken": 3600, "AuthorizationCode": 600, + "BackchannelAuthenticationRequest": 600, "DeviceCode": 600, + "Grant": 1209600, "IdToken": 3600, - "RefreshToken": 86400 + "Interaction": 3600, + "RefreshToken": 86400, + "Session": 1209600 } } } diff --git a/package-lock.json b/package-lock.json index b1e825606..34e7d8db0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@types/n3": "^1.10.4", "@types/node": "^14.18.0", "@types/nodemailer": "^6.4.4", + "@types/oidc-provider": "^7.8.1", "@types/pump": "^1.1.1", "@types/punycode": "^2.1.0", "@types/redis": "^2.8.30", @@ -48,7 +49,7 @@ "mime-types": "^2.1.34", "n3": "^1.13.0", "nodemailer": "^6.7.2", - "oidc-provider": "^6.31.1", + "oidc-provider": "^7.10.6", "pump": "^3.0.0", "punycode": "^2.1.1", "rdf-dereference": "^1.9.0", @@ -4177,14 +4178,6 @@ "node": ">= 8" } }, - "node_modules/@panva/asn1.js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", - "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/@rdfjs/types": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.0.1.tgz", @@ -4197,6 +4190,7 @@ "version": "0.14.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", + "dev": true, "engines": { "node": ">=6" } @@ -4236,6 +4230,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==", + "dev": true, "dependencies": { "defer-to-connect": "^1.0.1" }, @@ -4341,6 +4336,17 @@ "@types/node": "*" } }, + "node_modules/@types/cacheable-request": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.2.tgz", + "integrity": "sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA==", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "*", + "@types/node": "*", + "@types/responselike": "*" + } + }, "node_modules/@types/cheerio": { "version": "0.22.30", "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.30.tgz", @@ -4443,6 +4449,11 @@ "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.1.tgz", "integrity": "sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ==" }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", + "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" + }, "node_modules/@types/http-errors": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-1.8.0.tgz", @@ -4507,6 +4518,14 @@ "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz", "integrity": "sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==" }, + "node_modules/@types/keyv": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.3.tgz", + "integrity": "sha512-FXCJgyyN3ivVgRoml4h94G/p3kY+u/B86La+QptcqJaWtBWtmc6TtkNfS40n9bIvyLteHh7zXOtgbobORKPbDg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/koa": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.13.1.tgz", @@ -4605,6 +4624,14 @@ "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==", "dev": true }, + "node_modules/@types/oidc-provider": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@types/oidc-provider/-/oidc-provider-7.8.1.tgz", + "integrity": "sha512-MsmVKYFN9i27kfJh3hqj7F6aQNue6A/1aBKVJH07I3WYMriUDqMtYU0MWtheFPI1Tm9kwa0JHheaOdNRjuxboA==", + "dependencies": { + "@types/koa": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -4679,6 +4706,14 @@ "@types/bluebird": "*" } }, + "node_modules/@types/responselike": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", + "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/semver": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.6.tgz", @@ -5423,11 +5458,6 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=" - }, "node_modules/anymatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", @@ -5898,10 +5928,19 @@ "node": ">= 6.0.0" } }, + "node_modules/cacheable-lookup": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-6.0.4.tgz", + "integrity": "sha512-mbcDEZCkv2CZF4G01kr8eBd/5agkt9oCqz75tJMSIsquvRZ2sL6Hi5zGVKi/0OSC9oO1GHfJ2AV0ZIOY9vye0A==", + "engines": { + "node": ">=10.6.0" + } + }, "node_modules/cacheable-request": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==", + "dev": true, "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", @@ -5919,6 +5958,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, "dependencies": { "pump": "^3.0.0" }, @@ -5933,6 +5973,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, "engines": { "node": ">=8" } @@ -6681,6 +6722,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", + "dev": true, "dependencies": { "mimic-response": "^1.0.0" }, @@ -6726,7 +6768,8 @@ "node_modules/defer-to-connect": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", - "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==" + "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==", + "dev": true }, "node_modules/define-properties": { "version": "1.1.3", @@ -6771,9 +6814,13 @@ } }, "node_modules/destroy": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", - "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.1.0.tgz", + "integrity": "sha512-R5QZrOXxSs0JDUIU/VANvRJlQVMts9C0L76HToQdPdlftfZCE7W6dyH0G4GZ5UW9fRqUOhAoCE2aGekuu+3HjQ==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } }, "node_modules/detect-libc": { "version": "1.0.3", @@ -6939,7 +6986,8 @@ "node_modules/duplexer3": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", - "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=" + "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", + "dev": true }, "node_modules/ee-first": { "version": "1.1.1", @@ -8443,6 +8491,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, "dependencies": { "pump": "^3.0.0" }, @@ -8485,12 +8534,6 @@ "node": ">=10" } }, - "node_modules/git-raw-commits/node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, "node_modules/git-raw-commits/node_modules/through2": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz", @@ -8843,6 +8886,7 @@ "version": "9.6.0", "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==", + "dev": true, "dependencies": { "@sindresorhus/is": "^0.14.0", "@szmarczak/http-timer": "^1.1.2", @@ -9009,7 +9053,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -9021,7 +9064,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dev": true, "dependencies": { "has-symbols": "^1.0.2" }, @@ -9121,67 +9163,50 @@ } }, "node_modules/http-assert": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.4.1.tgz", - "integrity": "sha512-rdw7q6GTlibqVVbXr0CKelfV5iY8G2HqEUkhSk297BMbSpSL8crXC+9rjKoMcZZEsksX30le6f/4ul4E28gegw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", "dependencies": { "deep-equal": "~1.0.1", - "http-errors": "~1.7.2" + "http-errors": "~1.8.0" }, "engines": { "node": ">= 0.8" } }, - "node_modules/http-assert/node_modules/http-errors": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", - "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.1.1", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/http-assert/node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, "node_modules/http-cache-semantics": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==" }, "node_modules/http-errors": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.0.tgz", - "integrity": "sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", "dependencies": { "depd": "~1.1.2", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" + "toidentifier": "1.0.1" }, "engines": { "node": ">= 0.6" } }, - "node_modules/http-errors/node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, "node_modules/http-errors/node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/http-errors/node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, "node_modules/http-link-header": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http-link-header/-/http-link-header-1.0.3.tgz", @@ -9203,6 +9228,29 @@ "node": ">= 6" } }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/http2-wrapper/node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", @@ -9440,9 +9488,9 @@ } }, "node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "2.0.0", @@ -9602,9 +9650,12 @@ } }, "node_modules/is-generator-function": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.8.tgz", - "integrity": "sha512-2Omr/twNtufVZFr1GhxjOMFPAj2sjc/dKaIqBhvo4qciXfJmITGH6ZGd8eZYNHza8t1y0e01AuqRhJwfWp26WQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -10755,7 +10806,8 @@ "node_modules/json-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", - "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=" + "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=", + "dev": true }, "node_modules/json-parse-better-errors": { "version": "1.0.2", @@ -10919,6 +10971,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==", + "dev": true, "dependencies": { "json-buffer": "3.0.0" } @@ -10942,16 +10995,16 @@ } }, "node_modules/koa": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.1.tgz", - "integrity": "sha512-Lb2Dloc72auj5vK4X4qqL7B5jyDPQaZucc9sR/71byg7ryoD1NCaCm63CShk9ID9quQvDEi1bGR/iGjCG7As3w==", + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.4.tgz", + "integrity": "sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g==", "dependencies": { "accepts": "^1.3.5", "cache-content-type": "^1.0.0", "content-disposition": "~0.5.2", "content-type": "^1.0.4", "cookies": "~0.8.0", - "debug": "~3.1.0", + "debug": "^4.3.2", "delegates": "^1.0.0", "depd": "^2.0.0", "destroy": "^1.0.4", @@ -10962,7 +11015,7 @@ "http-errors": "^1.6.3", "is-generator-function": "^1.0.7", "koa-compose": "^4.1.0", - "koa-convert": "^1.2.0", + "koa-convert": "^2.0.0", "on-finished": "^2.3.0", "only": "~0.0.2", "parseurl": "^1.3.2", @@ -10980,31 +11033,15 @@ "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==" }, "node_modules/koa-convert": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-1.2.0.tgz", - "integrity": "sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA=", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", + "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", "dependencies": { "co": "^4.6.0", - "koa-compose": "^3.0.0" + "koa-compose": "^4.1.0" }, "engines": { - "node": ">= 4" - } - }, - "node_modules/koa-convert/node_modules/koa-compose": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-3.2.1.tgz", - "integrity": "sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec=", - "dependencies": { - "any-promise": "^1.1.0" - } - }, - "node_modules/koa/node_modules/debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dependencies": { - "ms": "2.0.0" + "node": ">= 10" } }, "node_modules/koa/node_modules/depd": { @@ -11206,6 +11243,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -11565,9 +11603,9 @@ } }, "node_modules/nanoid": { - "version": "3.1.22", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.22.tgz", - "integrity": "sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.0.tgz", + "integrity": "sha512-JzxqqT5u/x+/KOFSd7JP15DOo9nOoHpx6DYatqIHUW2+flybkm+mdcraotSQR5WcnZr+qhGVh8Ted0KdfSMxlg==", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -11806,6 +11844,7 @@ "version": "4.5.1", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz", "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==", + "dev": true, "engines": { "node": ">=8" } @@ -11868,9 +11907,9 @@ } }, "node_modules/object-hash": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.1.1.tgz", - "integrity": "sha512-VOJmgmS+7wvXf8CjbQmimtCnEx3IAoLxI3fp2fbWehxrWBcAQFbk+vcwb6vzR0VZv/eNCJ/27j151ZTwqW/JeQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", "engines": { "node": ">= 6" } @@ -11976,44 +12015,141 @@ } }, "node_modules/oidc-provider": { - "version": "6.31.1", - "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-6.31.1.tgz", - "integrity": "sha512-YYfcJKGrobdaW2v7bx5crd4yfxFTKAoOTHdQs/gsu6fwzc/yD/qjforQX2VgbTX+ySK8nm7EaAwJuJJSFRo1MA==", + "version": "7.10.6", + "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-7.10.6.tgz", + "integrity": "sha512-7fbnormUyTLP34dmR5WXoJtTWtfj6MsFNzIMKVRKv21e18NIXggn14EBUFC5rrMMtmeExb03+lJI/v+opD+0oQ==", "dependencies": { "@koa/cors": "^3.1.0", - "@types/koa": "^2.11.4", - "debug": "^4.1.1", - "ejs": "^3.1.5", - "got": "^9.6.0", - "jose": "^2.0.4", - "jsesc": "^3.0.1", - "koa": "^2.13.0", + "cacheable-lookup": "^6.0.1", + "debug": "^4.3.2", + "ejs": "^3.1.6", + "got": "^11.8.2", + "jose": "^4.1.4", + "jsesc": "^3.0.2", + "koa": "^2.13.3", "koa-compose": "^4.1.0", - "lru-cache": "^6.0.0", - "nanoid": "^3.1.10", - "object-hash": "^2.0.3", + "nanoid": "^3.1.28", + "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.1", + "paseto2": "npm:paseto@^2.1.3", + "quick-lru": "^5.1.1", "raw-body": "^2.4.1" }, "engines": { - "node": "^10.13.0 || >=12.0.0" + "node": "^12.19.0 || ^14.15.0 || ^16.13.0" }, "funding": { "url": "https://github.com/sponsors/panva" + }, + "optionalDependencies": { + "paseto3": "npm:paseto@^3.0.0" } }, - "node_modules/oidc-provider/node_modules/jose": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz", - "integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==", - "dependencies": { - "@panva/asn1.js": "^1.0.0" - }, + "node_modules/oidc-provider/node_modules/@sindresorhus/is": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.4.0.tgz", + "integrity": "sha512-QppPM/8l3Mawvh4rn9CNEYIU9bxpXUCRMaX9yUpvBk1nMKusLKpfXGDEKExKaPhLzcn3lzil7pR6rnJ11HgeRQ==", "engines": { - "node": ">=10.13.0 < 13 || >=13.7.0" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/panva" + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/oidc-provider/node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/oidc-provider/node_modules/cacheable-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz", + "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/oidc-provider/node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oidc-provider/node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "engines": { + "node": ">=10" + } + }, + "node_modules/oidc-provider/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oidc-provider/node_modules/got": { + "version": "11.8.3", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.3.tgz", + "integrity": "sha512-7gtQ5KiPh1RtGS9/Jbv1ofDpBFuq42gyfEib+ejaRBJuj/3tQFeR5+gw57e4ipaU8c/rCjvX6fkQz2lyDlGAOg==", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/oidc-provider/node_modules/got/node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "engines": { + "node": ">=10.6.0" } }, "node_modules/oidc-provider/node_modules/jsesc": { @@ -12027,6 +12163,76 @@ "node": ">=6" } }, + "node_modules/oidc-provider/node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, + "node_modules/oidc-provider/node_modules/keyv": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.1.1.tgz", + "integrity": "sha512-tGv1yP6snQVDSM4X6yxrv2zzq/EvpW+oYiUz6aueW1u9CtS8RzUQYxxmFwgZlO2jSgCxQbchhxaqXXp2hnKGpQ==", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/oidc-provider/node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/oidc-provider/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oidc-provider/node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oidc-provider/node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/oidc-provider/node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oidc-provider/node_modules/responselike": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz", + "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==", + "dependencies": { + "lowercase-keys": "^2.0.0" + } + }, "node_modules/oidc-token-hash": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz", @@ -12130,6 +12336,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==", + "dev": true, "engines": { "node": ">=6" } @@ -12255,6 +12462,31 @@ "node": ">= 0.8" } }, + "node_modules/paseto2": { + "name": "paseto", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/paseto/-/paseto-2.1.3.tgz", + "integrity": "sha512-BNkbvr0ZFDbh3oV13QzT5jXIu8xpFc9r0o5mvWBhDU1GBkVt1IzHK1N6dcYmN7XImrUmPQ0HCUXmoe2WPo8xsg==", + "engines": { + "node": "^12.19.0 || >=14.15.0" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/paseto3": { + "name": "paseto", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/paseto/-/paseto-3.1.0.tgz", + "integrity": "sha512-oVSKoCH89M0WU3I+13NoCP9wGRel0BlQumwxsDZPk1yJtqS76PWKRM7vM9D4bz4PcScT0aIiAipC7lW6hSgkBQ==", + "optional": true, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -12370,6 +12602,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=", + "dev": true, "engines": { "node": ">=4" } @@ -12571,11 +12804,6 @@ "node": ">= 0.6" } }, - "node_modules/raw-body/node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -13047,6 +13275,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" + }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -13090,6 +13323,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=", + "dev": true, "dependencies": { "lowercase-keys": "^1.0.0" } @@ -13972,6 +14206,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==", + "dev": true, "engines": { "node": ">=6" } @@ -14349,6 +14584,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=", + "dev": true, "dependencies": { "prepend-http": "^2.0.0" }, @@ -18080,11 +18316,6 @@ "fastq": "^1.6.0" } }, - "@panva/asn1.js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", - "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==" - }, "@rdfjs/types": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.0.1.tgz", @@ -18096,7 +18327,8 @@ "@sindresorhus/is": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", - "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==" + "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", + "dev": true }, "@sinonjs/commons": { "version": "1.8.3", @@ -18133,6 +18365,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==", + "dev": true, "requires": { "defer-to-connect": "^1.0.1" } @@ -18232,6 +18465,17 @@ "@types/node": "*" } }, + "@types/cacheable-request": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.2.tgz", + "integrity": "sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA==", + "requires": { + "@types/http-cache-semantics": "*", + "@types/keyv": "*", + "@types/node": "*", + "@types/responselike": "*" + } + }, "@types/cheerio": { "version": "0.22.30", "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.30.tgz", @@ -18334,6 +18578,11 @@ "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.1.tgz", "integrity": "sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ==" }, + "@types/http-cache-semantics": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", + "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" + }, "@types/http-errors": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-1.8.0.tgz", @@ -18398,6 +18647,14 @@ "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz", "integrity": "sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==" }, + "@types/keyv": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.3.tgz", + "integrity": "sha512-FXCJgyyN3ivVgRoml4h94G/p3kY+u/B86La+QptcqJaWtBWtmc6TtkNfS40n9bIvyLteHh7zXOtgbobORKPbDg==", + "requires": { + "@types/node": "*" + } + }, "@types/koa": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.13.1.tgz", @@ -18496,6 +18753,14 @@ "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==", "dev": true }, + "@types/oidc-provider": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/@types/oidc-provider/-/oidc-provider-7.8.1.tgz", + "integrity": "sha512-MsmVKYFN9i27kfJh3hqj7F6aQNue6A/1aBKVJH07I3WYMriUDqMtYU0MWtheFPI1Tm9kwa0JHheaOdNRjuxboA==", + "requires": { + "@types/koa": "*" + } + }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -18569,6 +18834,14 @@ "@types/bluebird": "*" } }, + "@types/responselike": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", + "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==", + "requires": { + "@types/node": "*" + } + }, "@types/semver": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.6.tgz", @@ -19071,11 +19344,6 @@ } } }, - "any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=" - }, "anymatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", @@ -19446,10 +19714,16 @@ "ylru": "^1.2.0" } }, + "cacheable-lookup": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-6.0.4.tgz", + "integrity": "sha512-mbcDEZCkv2CZF4G01kr8eBd/5agkt9oCqz75tJMSIsquvRZ2sL6Hi5zGVKi/0OSC9oO1GHfJ2AV0ZIOY9vye0A==" + }, "cacheable-request": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==", + "dev": true, "requires": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", @@ -19464,6 +19738,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, "requires": { "pump": "^3.0.0" } @@ -19471,7 +19746,8 @@ "lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==" + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true } } }, @@ -20071,6 +20347,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", + "dev": true, "requires": { "mimic-response": "^1.0.0" } @@ -20107,7 +20384,8 @@ "defer-to-connect": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", - "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==" + "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==", + "dev": true }, "define-properties": { "version": "1.1.3", @@ -20140,9 +20418,9 @@ "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" }, "destroy": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", - "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.1.0.tgz", + "integrity": "sha512-R5QZrOXxSs0JDUIU/VANvRJlQVMts9C0L76HToQdPdlftfZCE7W6dyH0G4GZ5UW9fRqUOhAoCE2aGekuu+3HjQ==" }, "detect-libc": { "version": "1.0.3", @@ -20261,7 +20539,8 @@ "duplexer3": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", - "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=" + "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", + "dev": true }, "ee-first": { "version": "1.1.1", @@ -21402,6 +21681,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, "requires": { "pump": "^3.0.0" } @@ -21429,12 +21709,6 @@ "through2": "^3.0.0" }, "dependencies": { - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, "through2": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz", @@ -21694,6 +21968,7 @@ "version": "9.6.0", "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==", + "dev": true, "requires": { "@sindresorhus/is": "^0.14.0", "@szmarczak/http-timer": "^1.1.2", @@ -21827,14 +22102,12 @@ "has-symbols": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", - "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", - "dev": true + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==" }, "has-tostringtag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dev": true, "requires": { "has-symbols": "^1.0.2" } @@ -21908,31 +22181,12 @@ } }, "http-assert": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.4.1.tgz", - "integrity": "sha512-rdw7q6GTlibqVVbXr0CKelfV5iY8G2HqEUkhSk297BMbSpSL8crXC+9rjKoMcZZEsksX30le6f/4ul4E28gegw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", "requires": { "deep-equal": "~1.0.1", - "http-errors": "~1.7.2" - }, - "dependencies": { - "http-errors": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", - "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", - "requires": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.1.1", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - } + "http-errors": "~1.8.0" } }, "http-cache-semantics": { @@ -21941,26 +22195,26 @@ "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==" }, "http-errors": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.0.tgz", - "integrity": "sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", "requires": { "depd": "~1.1.2", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" + "toidentifier": "1.0.1" }, "dependencies": { - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, "setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" } } }, @@ -21979,6 +22233,22 @@ "debug": "4" } }, + "http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "requires": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "dependencies": { + "quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==" + } + } + }, "https-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", @@ -22140,9 +22410,9 @@ } }, "inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "ini": { "version": "2.0.0", @@ -22256,9 +22526,12 @@ "dev": true }, "is-generator-function": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.8.tgz", - "integrity": "sha512-2Omr/twNtufVZFr1GhxjOMFPAj2sjc/dKaIqBhvo4qciXfJmITGH6ZGd8eZYNHza8t1y0e01AuqRhJwfWp26WQ==" + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "requires": { + "has-tostringtag": "^1.0.0" + } }, "is-glob": { "version": "4.0.3", @@ -23126,7 +23399,8 @@ "json-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", - "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=" + "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=", + "dev": true }, "json-parse-better-errors": { "version": "1.0.2", @@ -23265,6 +23539,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==", + "dev": true, "requires": { "json-buffer": "3.0.0" } @@ -23282,16 +23557,16 @@ "dev": true }, "koa": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.1.tgz", - "integrity": "sha512-Lb2Dloc72auj5vK4X4qqL7B5jyDPQaZucc9sR/71byg7ryoD1NCaCm63CShk9ID9quQvDEi1bGR/iGjCG7As3w==", + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.4.tgz", + "integrity": "sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g==", "requires": { "accepts": "^1.3.5", "cache-content-type": "^1.0.0", "content-disposition": "~0.5.2", "content-type": "^1.0.4", "cookies": "~0.8.0", - "debug": "~3.1.0", + "debug": "^4.3.2", "delegates": "^1.0.0", "depd": "^2.0.0", "destroy": "^1.0.4", @@ -23302,7 +23577,7 @@ "http-errors": "^1.6.3", "is-generator-function": "^1.0.7", "koa-compose": "^4.1.0", - "koa-convert": "^1.2.0", + "koa-convert": "^2.0.0", "on-finished": "^2.3.0", "only": "~0.0.2", "parseurl": "^1.3.2", @@ -23311,14 +23586,6 @@ "vary": "^1.1.2" }, "dependencies": { - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "requires": { - "ms": "2.0.0" - } - }, "depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -23332,22 +23599,12 @@ "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==" }, "koa-convert": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-1.2.0.tgz", - "integrity": "sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA=", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", + "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", "requires": { "co": "^4.6.0", - "koa-compose": "^3.0.0" - }, - "dependencies": { - "koa-compose": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-3.2.1.tgz", - "integrity": "sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec=", - "requires": { - "any-promise": "^1.1.0" - } - } + "koa-compose": "^4.1.0" } }, "kuler": { @@ -23520,7 +23777,8 @@ "lowercase-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", - "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==" + "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", + "dev": true }, "lru-cache": { "version": "6.0.0", @@ -23788,9 +24046,9 @@ } }, "nanoid": { - "version": "3.1.22", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.22.tgz", - "integrity": "sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ==" + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.0.tgz", + "integrity": "sha512-JzxqqT5u/x+/KOFSd7JP15DOo9nOoHpx6DYatqIHUW2+flybkm+mdcraotSQR5WcnZr+qhGVh8Ted0KdfSMxlg==" }, "natural-compare": { "version": "1.4.0", @@ -23976,7 +24234,8 @@ "normalize-url": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz", - "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==" + "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==", + "dev": true }, "npm-run-path": { "version": "4.0.1", @@ -24024,9 +24283,9 @@ "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-hash": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.1.1.tgz", - "integrity": "sha512-VOJmgmS+7wvXf8CjbQmimtCnEx3IAoLxI3fp2fbWehxrWBcAQFbk+vcwb6vzR0VZv/eNCJ/27j151ZTwqW/JeQ==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==" }, "object-inspect": { "version": "1.11.1", @@ -24099,38 +24358,151 @@ } }, "oidc-provider": { - "version": "6.31.1", - "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-6.31.1.tgz", - "integrity": "sha512-YYfcJKGrobdaW2v7bx5crd4yfxFTKAoOTHdQs/gsu6fwzc/yD/qjforQX2VgbTX+ySK8nm7EaAwJuJJSFRo1MA==", + "version": "7.10.6", + "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-7.10.6.tgz", + "integrity": "sha512-7fbnormUyTLP34dmR5WXoJtTWtfj6MsFNzIMKVRKv21e18NIXggn14EBUFC5rrMMtmeExb03+lJI/v+opD+0oQ==", "requires": { "@koa/cors": "^3.1.0", - "@types/koa": "^2.11.4", - "debug": "^4.1.1", - "ejs": "^3.1.5", - "got": "^9.6.0", - "jose": "^2.0.4", - "jsesc": "^3.0.1", - "koa": "^2.13.0", + "cacheable-lookup": "^6.0.1", + "debug": "^4.3.2", + "ejs": "^3.1.6", + "got": "^11.8.2", + "jose": "^4.1.4", + "jsesc": "^3.0.2", + "koa": "^2.13.3", "koa-compose": "^4.1.0", - "lru-cache": "^6.0.0", - "nanoid": "^3.1.10", - "object-hash": "^2.0.3", + "nanoid": "^3.1.28", + "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.1", + "paseto2": "npm:paseto@^2.1.3", + "paseto3": "npm:paseto@^3.0.0", + "quick-lru": "^5.1.1", "raw-body": "^2.4.1" }, "dependencies": { - "jose": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz", - "integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==", + "@sindresorhus/is": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.4.0.tgz", + "integrity": "sha512-QppPM/8l3Mawvh4rn9CNEYIU9bxpXUCRMaX9yUpvBk1nMKusLKpfXGDEKExKaPhLzcn3lzil7pR6rnJ11HgeRQ==" + }, + "@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", "requires": { - "@panva/asn1.js": "^1.0.0" + "defer-to-connect": "^2.0.0" + } + }, + "cacheable-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz", + "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==", + "requires": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + } + }, + "decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "requires": { + "mimic-response": "^3.1.0" + } + }, + "defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==" + }, + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "requires": { + "pump": "^3.0.0" + } + }, + "got": { + "version": "11.8.3", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.3.tgz", + "integrity": "sha512-7gtQ5KiPh1RtGS9/Jbv1ofDpBFuq42gyfEib+ejaRBJuj/3tQFeR5+gw57e4ipaU8c/rCjvX6fkQz2lyDlGAOg==", + "requires": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "dependencies": { + "cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==" + } } }, "jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==" + }, + "json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, + "keyv": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.1.1.tgz", + "integrity": "sha512-tGv1yP6snQVDSM4X6yxrv2zzq/EvpW+oYiUz6aueW1u9CtS8RzUQYxxmFwgZlO2jSgCxQbchhxaqXXp2hnKGpQ==", + "requires": { + "json-buffer": "3.0.1" + } + }, + "lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==" + }, + "mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" + }, + "normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==" + }, + "p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==" + }, + "quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==" + }, + "responselike": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz", + "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==", + "requires": { + "lowercase-keys": "^2.0.0" + } } } }, @@ -24212,7 +24584,8 @@ "p-cancelable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", - "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==" + "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==", + "dev": true }, "p-limit": { "version": "2.3.0", @@ -24307,6 +24680,17 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, + "paseto2": { + "version": "npm:paseto@2.1.3", + "resolved": "https://registry.npmjs.org/paseto/-/paseto-2.1.3.tgz", + "integrity": "sha512-BNkbvr0ZFDbh3oV13QzT5jXIu8xpFc9r0o5mvWBhDU1GBkVt1IzHK1N6dcYmN7XImrUmPQ0HCUXmoe2WPo8xsg==" + }, + "paseto3": { + "version": "npm:paseto@3.1.0", + "resolved": "https://registry.npmjs.org/paseto/-/paseto-3.1.0.tgz", + "integrity": "sha512-oVSKoCH89M0WU3I+13NoCP9wGRel0BlQumwxsDZPk1yJtqS76PWKRM7vM9D4bz4PcScT0aIiAipC7lW6hSgkBQ==", + "optional": true + }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -24388,7 +24772,8 @@ "prepend-http": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", - "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=" + "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=", + "dev": true }, "pretty-format": { "version": "27.4.6", @@ -24540,11 +24925,6 @@ "statuses": ">= 1.5.0 < 2", "toidentifier": "1.0.0" } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" } } }, @@ -24943,6 +25323,11 @@ "path-parse": "^1.0.6" } }, + "resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" + }, "resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -24976,6 +25361,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=", + "dev": true, "requires": { "lowercase-keys": "^1.0.0" } @@ -25693,7 +26079,8 @@ "to-readable-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", - "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==" + "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==", + "dev": true }, "to-regex-range": { "version": "5.0.1", @@ -25960,6 +26347,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=", + "dev": true, "requires": { "prepend-http": "^2.0.0" } diff --git a/package.json b/package.json index 1ddbc435d..814bbc0ce 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "@types/n3": "^1.10.4", "@types/node": "^14.18.0", "@types/nodemailer": "^6.4.4", + "@types/oidc-provider": "^7.8.1", "@types/pump": "^1.1.1", "@types/punycode": "^2.1.0", "@types/redis": "^2.8.30", @@ -114,7 +115,7 @@ "mime-types": "^2.1.34", "n3": "^1.13.0", "nodemailer": "^6.7.2", - "oidc-provider": "^6.31.1", + "oidc-provider": "^7.10.6", "pump": "^3.0.0", "punycode": "^2.1.1", "rdf-dereference": "^1.9.0", diff --git a/src/identity/OidcHttpHandler.ts b/src/identity/OidcHttpHandler.ts index a8a3fe924..03fb340d6 100644 --- a/src/identity/OidcHttpHandler.ts +++ b/src/identity/OidcHttpHandler.ts @@ -20,8 +20,8 @@ export class OidcHttpHandler extends HttpHandler { const provider = await this.providerFactory.getProvider(); this.logger.debug(`Sending request to oidc-provider: ${request.url}`); // Even though the typings do not indicate this, this is a Promise that needs to be awaited. - // Otherwise the `BaseHttpServerFactory` will write a 404 before the OIDC library could handle the response. + // Otherwise, the `BaseHttpServerFactory` will write a 404 before the OIDC library could handle the response. // eslint-disable-next-line @typescript-eslint/await-thenable - await provider.callback(request, response); + await provider.callback()(request, response); } } diff --git a/src/identity/configuration/IdentityProviderFactory.ts b/src/identity/configuration/IdentityProviderFactory.ts index fc3125422..3f094e459 100644 --- a/src/identity/configuration/IdentityProviderFactory.ts +++ b/src/identity/configuration/IdentityProviderFactory.ts @@ -4,13 +4,14 @@ import { randomBytes } from 'crypto'; import type { JWK } from 'jose'; import { exportJWK, generateKeyPair } from 'jose'; -import type { AnyObject, +import type { Account, + Adapter, CanBePromise, - KoaContextWithOIDC, Configuration, - Account, ErrorOut, - Adapter } from 'oidc-provider'; + KoaContextWithOIDC, + ResourceServer, + UnknownObject } from 'oidc-provider'; import { Provider } from 'oidc-provider'; import type { Operation } from '../../http/Operation'; import type { ErrorHandler } from '../../http/output/error/ErrorHandler'; @@ -75,6 +76,7 @@ export class IdentityProviderFactory implements ProviderFactory { private readonly errorHandler!: ErrorHandler; private readonly responseWriter!: ResponseWriter; + private readonly jwtAlg = 'ES256'; private provider?: Provider; /** @@ -139,11 +141,23 @@ export class IdentityProviderFactory implements ProviderFactory { keys: await this.generateCookieKeys(), }; + // Solid OIDC requires pkce https://solid.github.io/solid-oidc/#concepts + config.pkce = { + methods: [ 'S256' ], + required: (): true => true, + }; + + // Default client settings that might not be defined. + // Mostly relevant for WebID clients. + config.clientDefaults = { + id_token_signed_response_alg: this.jwtAlg, + }; + return config; } /** - * Generates a JWKS using a single RS256 JWK.. + * Generates a JWKS using a single JWK. * The JWKS will be cached so subsequent calls return the same key. */ private async generateJwks(): Promise<{ keys: JWK[] }> { @@ -153,10 +167,10 @@ export class IdentityProviderFactory implements ProviderFactory { return jwks; } // If they are not, generate and save them - const { privateKey } = await generateKeyPair('RS256'); + const { privateKey } = await generateKeyPair(this.jwtAlg); const jwk = await exportJWK(privateKey); // Required for Solid authn client - jwk.alg = 'RS256'; + jwk.alg = this.jwtAlg; // In node v15.12.0 the JWKS does not get accepted because the JWK is not a plain object, // which is why we convert it into a plain object here. // Potentially this can be changed at a later point in time to `{ keys: [ jwk ]}`. @@ -190,28 +204,51 @@ export class IdentityProviderFactory implements ProviderFactory { } /** - * Adds the necessary claims the to id token and access token based on the Solid OIDC spec. + * Adds the necessary claims the to id and access tokens based on the Solid OIDC spec. */ private configureClaims(config: Configuration): void { - // Access token audience is 'solid', ID token audience is the client_id - config.audiences = (ctx, sub, token, use): string => - use === 'access_token' ? 'solid' : token.clientId!; - // Returns the id_token // See https://solid.github.io/authentication-panel/solid-oidc/#tokens-id + // Some fields are still missing, see https://github.com/solid/community-server/issues/1154#issuecomment-1040233385 config.findAccount = async(ctx: KoaContextWithOIDC, sub: string): Promise => ({ accountId: sub, - claims: async(): Promise<{ sub: string; [key: string]: any }> => - ({ sub, webid: sub }), + async claims(): Promise<{ sub: string; [key: string]: any }> { + return { sub, webid: sub, azp: ctx.oidc.client?.clientId }; + }, }); // Add extra claims in case an AccessToken is being issued. // Specifically this sets the required webid and client_id claims for the access token - // See https://solid.github.io/authentication-panel/solid-oidc/#tokens-access - config.extraAccessTokenClaims = (ctx, token): CanBePromise => + // See https://solid.github.io/solid-oidc/#resource-access-validation + config.extraTokenClaims = (ctx, token): CanBePromise => this.isAccessToken(token) ? - { webid: token.accountId, client_id: token.clientId } : + { webid: token.accountId } : {}; + + config.features = { + ...config.features, + resourceIndicators: { + defaultResource(): string { + // This value is irrelevant, but is necessary to trigger the `getResourceServerInfo` call below, + // where it will be an input parameter in case the client provided no value. + // Note that an empty string is not a valid value. + return 'http://example.com/'; + }, + enabled: true, + // This call is necessary to force the OIDC library to return a JWT access token. + // See https://github.com/panva/node-oidc-provider/discussions/959#discussioncomment-524757 + getResourceServerInfo: (): ResourceServer => ({ + // The scopes of the Resource Server. + // Since this is irrelevant at the moment, an empty string is fine. + scope: '', + audience: 'solid', + accessTokenFormat: 'jwt', + jwt: { + sign: { alg: this.jwtAlg }, + }, + }), + }, + }; } /** @@ -230,6 +267,7 @@ export class IdentityProviderFactory implements ProviderFactory { // When oidc-provider cannot fulfill the authorization request for any of the possible reasons // (missing user session, requested ACR not fulfilled, prompt requested, ...) // it will resolve the interactions.url helper function and redirect the User-Agent to that url. + // Another requirement is that `features.userinfo` is disabled in the configuration. config.interactions = { url: async(ctx, oidcInteraction): Promise => { const operation: Operation = { @@ -255,7 +293,7 @@ export class IdentityProviderFactory implements ProviderFactory { config.routes = { authorization: this.createRoute('auth'), - check_session: this.createRoute('session/check'), + backchannel_authentication: this.createRoute('backchannel'), code_verification: this.createRoute('device'), device_authorization: this.createRoute('device/auth'), end_session: this.createRoute('session/end'), diff --git a/src/identity/interaction/CompletingInteractionHandler.ts b/src/identity/interaction/CompletingInteractionHandler.ts deleted file mode 100644 index 3a460011e..000000000 --- a/src/identity/interaction/CompletingInteractionHandler.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; -import { FoundHttpError } from '../../util/errors/FoundHttpError'; -import { BaseInteractionHandler } from './BaseInteractionHandler'; -import type { InteractionHandlerInput } from './InteractionHandler'; -import type { InteractionCompleterInput, InteractionCompleter } from './util/InteractionCompleter'; - -/** - * Abstract extension of {@link BaseInteractionHandler} for handlers that need to call an {@link InteractionCompleter}. - * This is required by handlers that handle IDP behaviour - * and need to complete an OIDC interaction by redirecting back to the client, - * such as when logging in. - * - * Calls the InteractionCompleter with the results returned by the helper function - * and throw a corresponding {@link FoundHttpError}. - */ -export abstract class CompletingInteractionHandler extends BaseInteractionHandler { - protected readonly interactionCompleter: InteractionCompleter; - - protected constructor(view: Record, interactionCompleter: InteractionCompleter) { - super(view); - this.interactionCompleter = interactionCompleter; - } - - public async canHandle(input: InteractionHandlerInput): Promise { - await super.canHandle(input); - if (input.operation.method === 'POST' && !input.oidcInteraction) { - throw new BadRequestHttpError( - 'This action can only be performed as part of an OIDC authentication flow.', - { errorCode: 'E0002' }, - ); - } - } - - public async handlePost(input: InteractionHandlerInput): Promise { - // Interaction is defined due to canHandle call - const parameters = await this.getCompletionParameters(input as Required); - const location = await this.interactionCompleter.handleSafe(parameters); - throw new FoundHttpError(location); - } - - /** - * Generates the parameters necessary to call an InteractionCompleter. - * The input parameters are the same that the `handlePost` function was called with. - * @param input - The original input parameters to the `handle` function. - */ - protected abstract getCompletionParameters(input: Required): - Promise; -} diff --git a/src/identity/interaction/ConsentHandler.ts b/src/identity/interaction/ConsentHandler.ts new file mode 100644 index 000000000..a7cf11144 --- /dev/null +++ b/src/identity/interaction/ConsentHandler.ts @@ -0,0 +1,111 @@ +import type { InteractionResults, KoaContextWithOIDC, UnknownObject } from 'oidc-provider'; +import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; +import { FoundHttpError } from '../../util/errors/FoundHttpError'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { readJsonStream } from '../../util/StreamUtil'; +import type { ProviderFactory } from '../configuration/ProviderFactory'; +import { BaseInteractionHandler } from './BaseInteractionHandler'; +import type { Interaction, InteractionHandlerInput } from './InteractionHandler'; + +type Grant = NonNullable; + +/** + * Handles the OIDC consent prompts where the user confirms they want to log in for the given client. + */ +export class ConsentHandler extends BaseInteractionHandler { + private readonly providerFactory: ProviderFactory; + + public constructor(providerFactory: ProviderFactory) { + super({}); + this.providerFactory = providerFactory; + } + + public async canHandle(input: InteractionHandlerInput): Promise { + await super.canHandle(input); + if (input.operation.method === 'POST' && !input.oidcInteraction) { + throw new BadRequestHttpError( + 'This action can only be performed as part of an OIDC authentication flow.', + { errorCode: 'E0002' }, + ); + } + } + + protected async handlePost({ operation, oidcInteraction }: InteractionHandlerInput): Promise { + const { remember } = await readJsonStream(operation.body.data); + + const grant = await this.getGrant(oidcInteraction!); + this.updateGrant(grant, oidcInteraction!.prompt.details, remember); + + const location = await this.updateInteraction(oidcInteraction!, grant); + + throw new FoundHttpError(location); + } + + /** + * Either returns the grant associated with the given interaction or creates a new one if it does not exist yet. + */ + private async getGrant(oidcInteraction: Interaction): Promise { + if (!oidcInteraction.session) { + throw new NotImplementedHttpError('Only interactions with a valid session are supported.'); + } + + const { params, session: { accountId }, grantId } = oidcInteraction; + const provider = await this.providerFactory.getProvider(); + let grant: Grant; + if (grantId) { + grant = (await provider.Grant.find(grantId))!; + } else { + grant = new provider.Grant({ + accountId, + clientId: params.client_id as string, + }); + } + return grant; + } + + /** + * Updates the grant with all the missing scopes and claims requested by the interaction. + * + * Will reject the `offline_access` scope if `remember` is false. + */ + private updateGrant(grant: Grant, details: UnknownObject, remember: boolean): void { + // Reject the offline_access scope if the user does not want to be remembered + if (!remember) { + grant.rejectOIDCScope('offline_access'); + } + + // Grant all the requested scopes and claims + if (details.missingOIDCScope) { + grant.addOIDCScope((details.missingOIDCScope as string[]).join(' ')); + } + if (details.missingOIDCClaims) { + grant.addOIDCClaims(details.missingOIDCClaims as string[]); + } + if (details.missingResourceScopes) { + for (const [ indicator, scopes ] of Object.entries(details.missingResourceScopes as Record)) { + grant.addResourceScope(indicator, scopes.join(' ')); + } + } + } + + /** + * Updates the interaction with the new grant and returns the resulting redirect URL. + */ + private async updateInteraction(oidcInteraction: Interaction, grant: Grant): Promise { + const grantId = await grant.save(); + + const consent: InteractionResults['consent'] = {}; + // Only need to update the grantId if it is new + if (!oidcInteraction.grantId) { + consent.grantId = grantId; + } + + const result: InteractionResults = { consent }; + + // Need to merge with previous submission + oidcInteraction.result = { ...oidcInteraction.lastSubmission, ...result }; + await oidcInteraction.save(oidcInteraction.exp - Math.floor(Date.now() / 1000)); + + return oidcInteraction.returnTo; + } +} diff --git a/src/identity/interaction/ExistingLoginHandler.ts b/src/identity/interaction/ExistingLoginHandler.ts deleted file mode 100644 index 94755405f..000000000 --- a/src/identity/interaction/ExistingLoginHandler.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; -import { readJsonStream } from '../../util/StreamUtil'; -import { CompletingInteractionHandler } from './CompletingInteractionHandler'; -import type { InteractionHandlerInput } from './InteractionHandler'; -import type { InteractionCompleter, InteractionCompleterInput } from './util/InteractionCompleter'; - -/** - * Simple CompletingInteractionRoute that returns the session accountId as webId. - * This is relevant when a client already logged in this session and tries logging in again. - */ -export class ExistingLoginHandler extends CompletingInteractionHandler { - public constructor(interactionCompleter: InteractionCompleter) { - super({}, interactionCompleter); - } - - protected async getCompletionParameters({ operation, oidcInteraction }: Required): - Promise { - if (!oidcInteraction.session) { - throw new NotImplementedHttpError('Only interactions with a valid session are supported.'); - } - - const { remember } = await readJsonStream(operation.body.data); - return { oidcInteraction, webId: oidcInteraction.session.accountId, shouldRemember: Boolean(remember) }; - } -} diff --git a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts index a4ad43ebc..500691273 100644 --- a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts @@ -8,8 +8,8 @@ import type { TemplateEngine } from '../../../../util/templates/TemplateEngine'; import { BaseInteractionHandler } from '../../BaseInteractionHandler'; import type { InteractionHandlerInput } from '../../InteractionHandler'; import type { InteractionRoute } from '../../routing/InteractionRoute'; -import type { EmailSender } from '../../util/EmailSender'; import type { AccountStore } from '../storage/AccountStore'; +import type { EmailSender } from '../util/EmailSender'; const forgotPasswordView = { required: { diff --git a/src/identity/interaction/email-password/handler/LoginHandler.ts b/src/identity/interaction/email-password/handler/LoginHandler.ts index 0f8e9f04c..6b6a9c1c3 100644 --- a/src/identity/interaction/email-password/handler/LoginHandler.ts +++ b/src/identity/interaction/email-password/handler/LoginHandler.ts @@ -1,11 +1,12 @@ import assert from 'assert'; +import type { InteractionResults } from 'oidc-provider'; import type { Operation } from '../../../../http/Operation'; import { getLoggerFor } from '../../../../logging/LogUtil'; import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; +import { FoundHttpError } from '../../../../util/errors/FoundHttpError'; import { readJsonStream } from '../../../../util/StreamUtil'; -import { CompletingInteractionHandler } from '../../CompletingInteractionHandler'; +import { BaseInteractionHandler } from '../../BaseInteractionHandler'; import type { InteractionHandlerInput } from '../../InteractionHandler'; -import type { InteractionCompleterInput, InteractionCompleter } from '../../util/InteractionCompleter'; import type { AccountStore } from '../storage/AccountStore'; const loginView = { @@ -26,19 +27,27 @@ interface LoginInput { * Handles the submission of the Login Form and logs the user in. * Will throw a RedirectHttpError on success. */ -export class LoginHandler extends CompletingInteractionHandler { +export class LoginHandler extends BaseInteractionHandler { protected readonly logger = getLoggerFor(this); private readonly accountStore: AccountStore; - public constructor(accountStore: AccountStore, interactionCompleter: InteractionCompleter) { - super(loginView, interactionCompleter); + public constructor(accountStore: AccountStore) { + super(loginView); this.accountStore = accountStore; } - protected async getCompletionParameters(input: Required): - Promise { - const { operation, oidcInteraction } = input; + public async canHandle(input: InteractionHandlerInput): Promise { + await super.canHandle(input); + if (input.operation.method === 'POST' && !input.oidcInteraction) { + throw new BadRequestHttpError( + 'This action can only be performed as part of an OIDC authentication flow.', + { errorCode: 'E0002' }, + ); + } + } + + public async handlePost({ operation, oidcInteraction }: InteractionHandlerInput): Promise { const { email, password, remember } = await this.parseInput(operation); // Try to log in, will error if email/password combination is invalid const webId = await this.accountStore.authenticate(email, password); @@ -49,7 +58,15 @@ export class LoginHandler extends CompletingInteractionHandler { } this.logger.debug(`Logging in user ${email}`); - return { oidcInteraction, webId, shouldRemember: remember }; + // Update the interaction to get the redirect URL + const login: InteractionResults['login'] = { + accountId: webId, + remember, + }; + oidcInteraction!.result = { login }; + await oidcInteraction!.save(oidcInteraction!.exp - Math.floor(Date.now() / 1000)); + + throw new FoundHttpError(oidcInteraction!.returnTo); } /** diff --git a/src/identity/interaction/util/BaseEmailSender.ts b/src/identity/interaction/email-password/util/BaseEmailSender.ts similarity index 100% rename from src/identity/interaction/util/BaseEmailSender.ts rename to src/identity/interaction/email-password/util/BaseEmailSender.ts diff --git a/src/identity/interaction/util/EmailSender.ts b/src/identity/interaction/email-password/util/EmailSender.ts similarity index 75% rename from src/identity/interaction/util/EmailSender.ts rename to src/identity/interaction/email-password/util/EmailSender.ts index 30a9a748d..ac6198e54 100644 --- a/src/identity/interaction/util/EmailSender.ts +++ b/src/identity/interaction/email-password/util/EmailSender.ts @@ -1,4 +1,4 @@ -import { AsyncHandler } from '../../../util/handlers/AsyncHandler'; +import { AsyncHandler } from '../../../../util/handlers/AsyncHandler'; export interface EmailArgs { recipient: string; diff --git a/src/identity/interaction/util/BaseInteractionCompleter.ts b/src/identity/interaction/util/BaseInteractionCompleter.ts deleted file mode 100644 index f70cc24a9..000000000 --- a/src/identity/interaction/util/BaseInteractionCompleter.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { InteractionResults } from 'oidc-provider'; -import type { InteractionCompleterInput } from './InteractionCompleter'; -import { InteractionCompleter } from './InteractionCompleter'; - -/** - * Creates a simple InteractionResults object based on the input parameters and injects it in the Interaction. - */ -export class BaseInteractionCompleter extends InteractionCompleter { - public async handle(input: InteractionCompleterInput): Promise { - const now = Math.floor(Date.now() / 1000); - const result: InteractionResults = { - login: { - account: input.webId, - // Indicates if a persistent cookie should be used instead of a session cookie. - remember: input.shouldRemember, - ts: now, - }, - consent: { - // When OIDC clients want a refresh token, they need to request the 'offline_access' scope. - // This indicates that this scope is not granted to the client in case they do not want to be remembered. - rejectedScopes: input.shouldRemember ? [] : [ 'offline_access' ], - }, - }; - - // Generates the URL a client needs to be redirected to - // after a successful interaction completion (such as logging in). - // Identical behaviour to calling `provider.interactionResult`. - // We use the code below instead of calling that function - // since that function also uses Request/Response objects to generate the Interaction object, - // which we already have here. - const { oidcInteraction } = input; - oidcInteraction.result = { ...oidcInteraction.lastSubmission, ...result }; - await oidcInteraction.save(oidcInteraction.exp - now); - - return oidcInteraction.returnTo; - } -} diff --git a/src/identity/interaction/util/InteractionCompleter.ts b/src/identity/interaction/util/InteractionCompleter.ts deleted file mode 100644 index 20dd4ecef..000000000 --- a/src/identity/interaction/util/InteractionCompleter.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { AsyncHandler } from '../../../util/handlers/AsyncHandler'; -import type { Interaction } from '../InteractionHandler'; - -/** - * Parameters required to specify how the interaction should be completed. - */ -export interface InteractionCompleterInput { - oidcInteraction: Interaction; - webId: string; - shouldRemember?: boolean; -} - -/** - * Class responsible for completing the interaction based on the parameters provided. - */ -export abstract class InteractionCompleter extends AsyncHandler {} diff --git a/src/index.ts b/src/index.ts index 12dd3b8e9..47587d80e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -139,6 +139,8 @@ export * from './identity/interaction/email-password/storage/AccountStore'; export * from './identity/interaction/email-password/storage/BaseAccountStore'; // Identity/Interaction/Email-Password/Util +export * from './identity/interaction/email-password/util/BaseEmailSender'; +export * from './identity/interaction/email-password/util/EmailSender'; export * from './identity/interaction/email-password/util/RegistrationManager'; // Identity/Interaction/Email-Password @@ -150,16 +152,9 @@ export * from './identity/interaction/routing/InteractionRoute'; export * from './identity/interaction/routing/InteractionRouteHandler'; export * from './identity/interaction/routing/RelativePathInteractionRoute'; -// Identity/Interaction/Util -export * from './identity/interaction/util/BaseEmailSender'; -export * from './identity/interaction/util/BaseInteractionCompleter'; -export * from './identity/interaction/util/EmailSender'; -export * from './identity/interaction/util/InteractionCompleter'; - // Identity/Interaction export * from './identity/interaction/BaseInteractionHandler'; -export * from './identity/interaction/CompletingInteractionHandler'; -export * from './identity/interaction/ExistingLoginHandler'; +export * from './identity/interaction/ConsentHandler'; export * from './identity/interaction/ControlHandler'; export * from './identity/interaction/FixedInteractionHandler'; export * from './identity/interaction/HtmlViewHandler'; diff --git a/test/integration/Identity.test.ts b/test/integration/Identity.test.ts index 3be14c145..5e3ddacf0 100644 --- a/test/integration/Identity.test.ts +++ b/test/integration/Identity.test.ts @@ -138,10 +138,11 @@ describe('A Solid server with IDP', (): void => { }); it('initializes the session and logs in.', async(): Promise => { - const url = await state.startSession(); + let url = await state.startSession(); const res = await state.fetchIdp(url); expect(res.status).toBe(200); - await state.login(url, email, password); + url = await state.login(url, email, password); + await state.consent(url); expect(state.session.info?.webId).toBe(webId); }); @@ -162,16 +163,12 @@ describe('A Solid server with IDP', (): void => { it('can log in again.', async(): Promise => { const url = await state.startSession(); - let res = await state.fetchIdp(url); + const res = await state.fetchIdp(url); expect(res.status).toBe(200); // Will receive confirm screen here instead of login screen - res = await state.fetchIdp(url, 'POST', '', APPLICATION_X_WWW_FORM_URLENCODED); - const json = await res.json(); - const nextUrl = json.location; - expect(typeof nextUrl).toBe('string'); + await state.consent(url); - await state.handleLoginRedirect(nextUrl); expect(state.session.info?.webId).toBe(webId); }); }); @@ -223,10 +220,11 @@ describe('A Solid server with IDP', (): void => { }); it('initializes the session and logs in.', async(): Promise => { - const url = await state.startSession(clientId); + let url = await state.startSession(clientId); const res = await state.fetchIdp(url); expect(res.status).toBe(200); - await state.login(url, email, password); + url = await state.login(url, email, password); + await state.consent(url); expect(state.session.info?.webId).toBe(webId); }); @@ -318,7 +316,8 @@ describe('A Solid server with IDP', (): void => { }); it('can log in with the new password.', async(): Promise => { - await state.login(nextUrl, email, password2); + const url = await state.login(nextUrl, email, password2); + await state.consent(url); expect(state.session.info?.webId).toBe(webId); }); }); @@ -397,10 +396,11 @@ describe('A Solid server with IDP', (): void => { it('initializes the session and logs in.', async(): Promise => { state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer); - const url = await state.startSession(); + let url = await state.startSession(); const res = await state.fetchIdp(url); expect(res.status).toBe(200); - await state.login(url, newMail, password); + url = await state.login(url, newMail, password); + await state.consent(url); expect(state.session.info?.webId).toBe(newWebId); }); diff --git a/test/integration/IdentityTestState.ts b/test/integration/IdentityTestState.ts index 17f4364bb..836fad1ad 100644 --- a/test/integration/IdentityTestState.ts +++ b/test/integration/IdentityTestState.ts @@ -89,7 +89,7 @@ export class IdentityTestState { // Need to catch the redirect so we can copy the cookies let res = await this.fetchIdp(nextUrl); - expect(res.status).toBe(302); + expect(res.status).toBe(303); nextUrl = res.headers.get('location')!; // Handle redirect @@ -109,22 +109,26 @@ export class IdentityTestState { * Logs in by sending the corresponding email and password to the given form action. * The URL should be extracted from the login page. */ - public async login(url: string, email: string, password: string): Promise { + public async login(url: string, email: string, password: string): Promise { const formData = stringify({ email, password }); - const res = await this.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED); + let res = await this.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED); expect(res.status).toBe(200); const json = await res.json(); - const nextUrl = json.location; - - return this.handleLoginRedirect(nextUrl); + res = await this.fetchIdp(json.location); + expect(res.status).toBe(303); + return res.headers.get('location')!; } /** - * Handles the redirect that happens after logging in. + * Handles the consent screen at the given URL and the followup redirect back to the client. */ - public async handleLoginRedirect(url: string): Promise { - const res = await this.fetchIdp(url); - expect(res.status).toBe(302); + public async consent(url: string): Promise { + let res = await this.fetchIdp(url, 'POST', '', APPLICATION_X_WWW_FORM_URLENCODED); + expect(res.status).toBe(200); + const json = await res.json(); + + res = await this.fetchIdp(json.location); + expect(res.status).toBe(303); const mockUrl = res.headers.get('location')!; expect(mockUrl.startsWith(this.redirectUrl)).toBeTruthy(); diff --git a/test/integration/RestrictedIdentity.test.ts b/test/integration/RestrictedIdentity.test.ts index c70581a44..6295d937f 100644 --- a/test/integration/RestrictedIdentity.test.ts +++ b/test/integration/RestrictedIdentity.test.ts @@ -94,10 +94,11 @@ describe('A server with restricted IDP access', (): void => { it('can still access registration with the correct credentials.', async(): Promise => { // Logging into session const state = new IdentityTestState(baseUrl, 'http://mockedredirect/', baseUrl); - const url = await state.startSession(); + let url = await state.startSession(); let res = await state.fetchIdp(url); expect(res.status).toBe(200); - await state.login(url, settings.email, settings.password); + url = await state.login(url, settings.email, settings.password); + await state.consent(url); expect(state.session.info?.webId).toBe(webId); // Registration still works for this WebID diff --git a/test/unit/identity/OidcHttpHandler.test.ts b/test/unit/identity/OidcHttpHandler.test.ts index 61be21c83..7ceba4473 100644 --- a/test/unit/identity/OidcHttpHandler.test.ts +++ b/test/unit/identity/OidcHttpHandler.test.ts @@ -13,7 +13,7 @@ describe('An OidcHttpHandler', (): void => { beforeEach(async(): Promise => { provider = { - callback: jest.fn(), + callback: jest.fn().mockReturnValue(jest.fn()), } as any; providerFactory = { @@ -26,6 +26,7 @@ describe('An OidcHttpHandler', (): void => { it('sends all requests to the OIDC library.', async(): Promise => { await expect(handler.handle({ request, response })).resolves.toBeUndefined(); expect(provider.callback).toHaveBeenCalledTimes(1); - expect(provider.callback).toHaveBeenLastCalledWith(request, response); + expect(provider.callback.mock.results[0].value).toHaveBeenCalledTimes(1); + expect(provider.callback.mock.results[0].value).toHaveBeenLastCalledWith(request, response); }); }); diff --git a/test/unit/identity/configuration/IdentityProviderFactory.test.ts b/test/unit/identity/configuration/IdentityProviderFactory.test.ts index bffb4787e..ceffb5a6d 100644 --- a/test/unit/identity/configuration/IdentityProviderFactory.test.ts +++ b/test/unit/identity/configuration/IdentityProviderFactory.test.ts @@ -16,7 +16,7 @@ jest.mock('oidc-provider', (): any => ({ const routes = { authorization: '/foo/oidc/auth', - check_session: '/foo/oidc/session/check', + backchannel_authentication: '/foo/oidc/backchannel', code_verification: '/foo/oidc/device', device_authorization: '/foo/oidc/device/auth', end_session: '/foo/oidc/session/end', @@ -100,23 +100,32 @@ describe('An IdentityProviderFactory', (): void => { expect(adapterFactory.createStorageAdapter).toHaveBeenLastCalledWith('test!'); expect(config.cookies?.keys).toEqual([ expect.any(String) ]); - expect(config.jwks).toEqual({ keys: [ expect.objectContaining({ kty: 'RSA' }) ]}); + expect(config.jwks).toEqual({ keys: [ expect.objectContaining({ alg: 'ES256' }) ]}); expect(config.routes).toEqual(routes); + expect(config.pkce?.methods).toEqual([ 'S256' ]); + expect((config.pkce!.required as any)()).toBe(true); + expect(config.clientDefaults?.id_token_signed_response_alg).toBe('ES256'); await expect((config.interactions?.url as any)(ctx, oidcInteraction)).resolves.toBe(redirectUrl); - expect((config.audiences as any)(null, null, {}, 'access_token')).toBe('solid'); - expect((config.audiences as any)(null, null, { clientId: 'clientId' }, 'client_credentials')).toBe('clientId'); - const findResult = await config.findAccount?.({ oidc: { client: { clientId: 'clientId' }}} as any, webId); + let findResult = await config.findAccount?.({ oidc: { client: { clientId: 'clientId' }}} as any, webId); expect(findResult?.accountId).toBe(webId); + await expect((findResult?.claims as any)()).resolves.toEqual({ sub: webId, webid: webId, azp: 'clientId' }); + findResult = await config.findAccount?.({ oidc: {}} as any, webId); await expect((findResult?.claims as any)()).resolves.toEqual({ sub: webId, webid: webId }); - expect((config.extraAccessTokenClaims as any)({}, {})).toEqual({}); - expect((config.extraAccessTokenClaims as any)({}, { kind: 'AccessToken', accountId: webId, clientId: 'clientId' })) - .toEqual({ - webid: webId, - client_id: 'clientId', - }); + expect((config.extraTokenClaims as any)({}, {})).toEqual({}); + expect((config.extraTokenClaims as any)({}, { kind: 'AccessToken', accountId: webId, clientId: 'clientId' })) + .toEqual({ webid: webId }); + + expect(config.features?.resourceIndicators?.enabled).toBe(true); + expect((config.features?.resourceIndicators?.defaultResource as any)()).toBe('http://example.com/'); + expect((config.features?.resourceIndicators?.getResourceServerInfo as any)()).toEqual({ + scope: '', + audience: 'solid', + accessTokenFormat: 'jwt', + jwt: { sign: { alg: 'ES256' }}, + }); // Test the renderError function const response = { } as HttpResponse; diff --git a/test/unit/identity/interaction/CompletingInteractionHandler.test.ts b/test/unit/identity/interaction/CompletingInteractionHandler.test.ts deleted file mode 100644 index 34a221d4f..000000000 --- a/test/unit/identity/interaction/CompletingInteractionHandler.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { Operation } from '../../../../src/http/Operation'; -import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; -import { CompletingInteractionHandler } from '../../../../src/identity/interaction/CompletingInteractionHandler'; -import type { - Interaction, - InteractionHandlerInput, -} from '../../../../src/identity/interaction/InteractionHandler'; -import type { - InteractionCompleter, - InteractionCompleterInput, -} from '../../../../src/identity/interaction/util/InteractionCompleter'; -import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; - -const webId = 'http://alice.test.com/card#me'; -class DummyCompletingInteractionHandler extends CompletingInteractionHandler { - public constructor(interactionCompleter: InteractionCompleter) { - super({}, interactionCompleter); - } - - public async getCompletionParameters(input: Required): Promise { - return { webId, oidcInteraction: input.oidcInteraction }; - } -} - -describe('A CompletingInteractionHandler', (): void => { - const oidcInteraction: Interaction = {} as any; - const location = 'http://test.com/redirect'; - let operation: Operation; - let interactionCompleter: jest.Mocked; - let handler: DummyCompletingInteractionHandler; - - beforeEach(async(): Promise => { - const representation = new BasicRepresentation('', 'application/json'); - operation = { - method: 'POST', - body: representation, - } as any; - - interactionCompleter = { - handleSafe: jest.fn().mockResolvedValue(location), - } as any; - - handler = new DummyCompletingInteractionHandler(interactionCompleter); - }); - - it('calls the parent JSON canHandle check.', async(): Promise => { - operation.body.metadata.contentType = 'application/x-www-form-urlencoded'; - await expect(handler.canHandle({ operation, oidcInteraction } as any)).rejects.toThrow(NotImplementedHttpError); - }); - - it('can handle GET requests without interaction.', async(): Promise => { - operation.method = 'GET'; - await expect(handler.canHandle({ operation } as any)).resolves.toBeUndefined(); - }); - - it('errors if no OidcInteraction is defined on POST requests.', async(): Promise => { - const error = expect.objectContaining({ - statusCode: 400, - message: 'This action can only be performed as part of an OIDC authentication flow.', - errorCode: 'E0002', - }); - await expect(handler.canHandle({ operation })).rejects.toThrow(error); - - await expect(handler.canHandle({ operation, oidcInteraction })).resolves.toBeUndefined(); - }); - - it('throws a redirect error with the completer location.', async(): Promise => { - const error = expect.objectContaining({ - statusCode: 302, - location, - }); - await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(error); - expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); - expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ oidcInteraction, webId }); - }); -}); diff --git a/test/unit/identity/interaction/ConsentHandler.test.ts b/test/unit/identity/interaction/ConsentHandler.test.ts new file mode 100644 index 000000000..28cf92890 --- /dev/null +++ b/test/unit/identity/interaction/ConsentHandler.test.ts @@ -0,0 +1,142 @@ +import type { Provider } from 'oidc-provider'; +import type { ProviderFactory } from '../../../../src/identity/configuration/ProviderFactory'; +import { ConsentHandler } from '../../../../src/identity/interaction/ConsentHandler'; +import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler'; +import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import { createPostJsonOperation } from './email-password/handler/Util'; + +const newGrantId = 'newGrantId'; +class DummyGrant { + public accountId: string; + public clientId: string; + + public readonly scopes: string[] = []; + public claims: string[] = []; + public readonly rejectedScopes: string[] = []; + public readonly resourceScopes: Record = {}; + + public constructor(props: { accountId: string; clientId: string }) { + this.accountId = props.accountId; + this.clientId = props.clientId; + } + + public rejectOIDCScope(scope: string): void { + this.rejectedScopes.push(scope); + } + + public addOIDCScope(scope: string): void { + this.scopes.push(scope); + } + + public addOIDCClaims(claims: string[]): void { + this.claims = claims; + } + + public addResourceScope(resource: string, scope: string): void { + this.resourceScopes[resource] = scope; + } + + public async save(): Promise { + return newGrantId; + } +} + +describe('A ConsentHandler', (): void => { + const accountId = 'http://example.com/id#me'; + const clientId = 'clientId'; + let grantFn: jest.Mock & { find: jest.Mock }; + let knownGrant: DummyGrant; + let oidcInteraction: Interaction; + let provider: jest.Mocked; + let providerFactory: jest.Mocked; + let handler: ConsentHandler; + + beforeEach(async(): Promise => { + oidcInteraction = { + session: { accountId }, + // eslint-disable-next-line @typescript-eslint/naming-convention + params: { client_id: clientId }, + prompt: { details: {}}, + save: jest.fn(), + } as any; + + knownGrant = new DummyGrant({ accountId, clientId }); + + grantFn = jest.fn((props): DummyGrant => new DummyGrant(props)) as any; + grantFn.find = jest.fn((grantId: string): any => grantId ? knownGrant : undefined); + provider = { + // eslint-disable-next-line @typescript-eslint/naming-convention + Grant: grantFn, + } as any; + + providerFactory = { + getProvider: jest.fn().mockResolvedValue(provider), + }; + + handler = new ConsentHandler(providerFactory); + }); + + it('errors if no oidcInteraction is defined on POST requests.', async(): Promise => { + const error = expect.objectContaining({ + statusCode: 400, + message: 'This action can only be performed as part of an OIDC authentication flow.', + errorCode: 'E0002', + }); + await expect(handler.canHandle({ operation: createPostJsonOperation({}) })).rejects.toThrow(error); + + await expect(handler.canHandle({ operation: createPostJsonOperation({}), oidcInteraction })) + .resolves.toBeUndefined(); + }); + + it('requires an oidcInteraction with a defined session.', async(): Promise => { + oidcInteraction.session = undefined; + await expect(handler.handle({ operation: createPostJsonOperation({}), oidcInteraction })) + .rejects.toThrow(NotImplementedHttpError); + }); + + it('throws a redirect error.', async(): Promise => { + const operation = createPostJsonOperation({}); + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); + }); + + it('stores the requested scopes and claims in the grant.', async(): Promise => { + oidcInteraction.prompt.details = { + missingOIDCScope: [ 'scope1', 'scope2' ], + missingOIDCClaims: [ 'claim1', 'claim2' ], + missingResourceScopes: { resource: [ 'scope1', 'scope2' ]}, + }; + + const operation = createPostJsonOperation({ remember: true }); + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); + expect(grantFn.mock.results).toHaveLength(1); + expect(grantFn.mock.results[0].value.scopes).toEqual([ 'scope1 scope2' ]); + expect(grantFn.mock.results[0].value.claims).toEqual([ 'claim1', 'claim2' ]); + expect(grantFn.mock.results[0].value.resourceScopes).toEqual({ resource: 'scope1 scope2' }); + expect(grantFn.mock.results[0].value.rejectedScopes).toEqual([]); + }); + + it('creates a new Grant when needed.', async(): Promise => { + const operation = createPostJsonOperation({}); + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); + expect(grantFn).toHaveBeenCalledTimes(1); + expect(grantFn).toHaveBeenLastCalledWith({ accountId, clientId }); + expect(grantFn.find).toHaveBeenCalledTimes(0); + }); + + it('reuses existing Grant objects.', async(): Promise => { + const operation = createPostJsonOperation({}); + oidcInteraction.grantId = '123456'; + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); + expect(grantFn).toHaveBeenCalledTimes(0); + expect(grantFn.find).toHaveBeenCalledTimes(1); + expect(grantFn.find).toHaveBeenLastCalledWith('123456'); + }); + + it('rejectes offline_access as scope if a user does not want to be remembered.', async(): Promise => { + const operation = createPostJsonOperation({}); + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); + expect(grantFn.mock.results).toHaveLength(1); + expect(grantFn.mock.results[0].value.rejectedScopes).toEqual([ 'offline_access' ]); + }); +}); diff --git a/test/unit/identity/interaction/ExistingLoginHandler.test.ts b/test/unit/identity/interaction/ExistingLoginHandler.test.ts deleted file mode 100644 index c649e0a23..000000000 --- a/test/unit/identity/interaction/ExistingLoginHandler.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ExistingLoginHandler } from '../../../../src/identity/interaction/ExistingLoginHandler'; -import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler'; -import type { - InteractionCompleter, -} from '../../../../src/identity/interaction/util/InteractionCompleter'; -import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; -import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; -import { createPostJsonOperation } from './email-password/handler/Util'; - -describe('An ExistingLoginHandler', (): void => { - const webId = 'http://test.com/id#me'; - let oidcInteraction: Interaction; - let interactionCompleter: jest.Mocked; - let handler: ExistingLoginHandler; - - beforeEach(async(): Promise => { - oidcInteraction = { session: { accountId: webId }} as any; - - interactionCompleter = { - handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'), - } as any; - - handler = new ExistingLoginHandler(interactionCompleter); - }); - - it('requires an oidcInteraction with a defined session.', async(): Promise => { - oidcInteraction.session = undefined; - await expect(handler.handle({ operation: createPostJsonOperation({}), oidcInteraction })) - .rejects.toThrow(NotImplementedHttpError); - }); - - it('returns the correct completion parameters.', async(): Promise => { - const operation = createPostJsonOperation({ remember: true }); - await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); - expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); - expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ oidcInteraction, webId, shouldRemember: true }); - }); -}); diff --git a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts index 92701c0a3..75a5a7640 100644 --- a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts @@ -3,8 +3,8 @@ import { ForgotPasswordHandler, } from '../../../../../../src/identity/interaction/email-password/handler/ForgotPasswordHandler'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; +import type { EmailSender } from '../../../../../../src/identity/interaction/email-password/util/EmailSender'; import type { InteractionRoute } from '../../../../../../src/identity/interaction/routing/InteractionRoute'; -import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender'; import { readJsonStream } from '../../../../../../src/util/StreamUtil'; import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine'; import { createPostJsonOperation } from './Util'; diff --git a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts index c4a9392e7..2ea04b774 100644 --- a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts @@ -4,22 +4,23 @@ import type { Interaction, InteractionHandlerInput, } from '../../../../../../src/identity/interaction/InteractionHandler'; -import type { - InteractionCompleter, -} from '../../../../../../src/identity/interaction/util/InteractionCompleter'; import { FoundHttpError } from '../../../../../../src/util/errors/FoundHttpError'; import { createPostJsonOperation } from './Util'; describe('A LoginHandler', (): void => { const webId = 'http://alice.test.com/card#me'; const email = 'alice@test.email'; - const oidcInteraction: Interaction = {} as any; + let oidcInteraction: jest.Mocked; let input: Required; let accountStore: jest.Mocked; - let interactionCompleter: jest.Mocked; let handler: LoginHandler; beforeEach(async(): Promise => { + oidcInteraction = { + exp: 123456, + save: jest.fn(), + } as any; + input = { oidcInteraction } as any; accountStore = { @@ -27,11 +28,18 @@ describe('A LoginHandler', (): void => { getSettings: jest.fn().mockResolvedValue({ useIdp: true }), } as any; - interactionCompleter = { - handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'), - } as any; + handler = new LoginHandler(accountStore); + }); + it('errors if no oidcInteraction is defined on POST requests.', async(): Promise => { + const error = expect.objectContaining({ + statusCode: 400, + message: 'This action can only be performed as part of an OIDC authentication flow.', + errorCode: 'E0002', + }); + await expect(handler.canHandle({ operation: createPostJsonOperation({}) })).rejects.toThrow(error); - handler = new LoginHandler(accountStore, interactionCompleter); + await expect(handler.canHandle({ operation: createPostJsonOperation({}), oidcInteraction })) + .resolves.toBeUndefined(); }); it('errors on invalid emails.', async(): Promise => { @@ -61,13 +69,13 @@ describe('A LoginHandler', (): void => { .rejects.toThrow('This server is not an identity provider for this account.'); }); - it('returns the correct completion parameters.', async(): Promise => { + it('returns the generated redirect URL.', async(): Promise => { input.operation = createPostJsonOperation({ email, password: 'password!' }); await expect(handler.handle(input)).rejects.toThrow(FoundHttpError); expect(accountStore.authenticate).toHaveBeenCalledTimes(1); expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, 'password!'); - expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); - expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ oidcInteraction, webId, shouldRemember: false }); + expect(oidcInteraction.save).toHaveBeenCalledTimes(1); + expect(oidcInteraction.result).toEqual({ login: { accountId: webId, remember: false }}); }); }); diff --git a/test/unit/identity/interaction/util/BaseEmailSender.test.ts b/test/unit/identity/interaction/email-password/util/BaseEmailSender.test.ts similarity index 82% rename from test/unit/identity/interaction/util/BaseEmailSender.test.ts rename to test/unit/identity/interaction/email-password/util/BaseEmailSender.test.ts index a04fa4424..4a2839f29 100644 --- a/test/unit/identity/interaction/util/BaseEmailSender.test.ts +++ b/test/unit/identity/interaction/email-password/util/BaseEmailSender.test.ts @@ -1,6 +1,6 @@ -import type { EmailSenderArgs } from '../../../../../src/identity/interaction/util/BaseEmailSender'; -import { BaseEmailSender } from '../../../../../src/identity/interaction/util/BaseEmailSender'; -import type { EmailArgs } from '../../../../../src/identity/interaction/util/EmailSender'; +import type { EmailSenderArgs } from '../../../../../../src/identity/interaction/email-password/util/BaseEmailSender'; +import { BaseEmailSender } from '../../../../../../src/identity/interaction/email-password/util/BaseEmailSender'; +import type { EmailArgs } from '../../../../../../src/identity/interaction/email-password/util/EmailSender'; jest.mock('nodemailer'); describe('A BaseEmailSender', (): void => { diff --git a/test/unit/identity/interaction/util/BaseInteractionCompleter.test.ts b/test/unit/identity/interaction/util/BaseInteractionCompleter.test.ts deleted file mode 100644 index 4973e6354..000000000 --- a/test/unit/identity/interaction/util/BaseInteractionCompleter.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { Interaction } from '../../../../../src/identity/interaction/InteractionHandler'; -import { BaseInteractionCompleter } from '../../../../../src/identity/interaction/util/BaseInteractionCompleter'; - -jest.useFakeTimers(); - -describe('A BaseInteractionCompleter', (): void => { - const now = Math.floor(Date.now() / 1000); - const webId = 'http://alice.test.com/#me'; - let oidcInteraction: jest.Mocked; - let completer: BaseInteractionCompleter; - - beforeEach(async(): Promise => { - oidcInteraction = { - lastSubmission: {}, - exp: now + 500, - returnTo: 'http://test.com/redirect', - save: jest.fn(), - } as any; - - completer = new BaseInteractionCompleter(); - }); - - it('stores the correct data in the interaction.', async(): Promise => { - await expect(completer.handle({ oidcInteraction, webId, shouldRemember: true })) - .resolves.toBe(oidcInteraction.returnTo); - expect(oidcInteraction.result).toEqual({ - login: { - account: webId, - remember: true, - ts: now, - }, - consent: { - rejectedScopes: [], - }, - }); - expect(oidcInteraction.save).toHaveBeenCalledTimes(1); - expect(oidcInteraction.save).toHaveBeenLastCalledWith(500); - }); - - it('rejects offline access if shouldRemember is false.', async(): Promise => { - await expect(completer.handle({ oidcInteraction, webId, shouldRemember: false })) - .resolves.toBe(oidcInteraction.returnTo); - expect(oidcInteraction.result).toEqual({ - login: { - account: webId, - remember: false, - ts: now, - }, - consent: { - rejectedScopes: [ 'offline_access' ], - }, - }); - expect(oidcInteraction.save).toHaveBeenCalledTimes(1); - expect(oidcInteraction.save).toHaveBeenLastCalledWith(500); - }); -}); From e9e3c6df3c945e187ae351f15bfe1a6df75e47a9 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 15 Feb 2022 17:34:46 +0100 Subject: [PATCH 31/39] fix: Improve OIDC error descriptions --- .../configuration/IdentityProviderFactory.ts | 6 ++++++ .../IdentityProviderFactory.test.ts | 20 ++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/identity/configuration/IdentityProviderFactory.ts b/src/identity/configuration/IdentityProviderFactory.ts index 3f094e459..93b5545ac 100644 --- a/src/identity/configuration/IdentityProviderFactory.ts +++ b/src/identity/configuration/IdentityProviderFactory.ts @@ -314,6 +314,12 @@ export class IdentityProviderFactory implements ProviderFactory { config.renderError = async(ctx: KoaContextWithOIDC, out: ErrorOut, error: Error): Promise => { // This allows us to stream directly to the response object, see https://github.com/koajs/koa/issues/944 ctx.respond = false; + + // OIDC library hides extra details in this field + if (out.error_description) { + error.message += ` - ${out.error_description}`; + } + const result = await this.errorHandler.handleSafe({ error, preferences: { type: { 'text/plain': 1 }}}); await this.responseWriter.handleSafe({ response: ctx.res, result }); }; diff --git a/test/unit/identity/configuration/IdentityProviderFactory.test.ts b/test/unit/identity/configuration/IdentityProviderFactory.test.ts index ceffb5a6d..10b4aa10f 100644 --- a/test/unit/identity/configuration/IdentityProviderFactory.test.ts +++ b/test/unit/identity/configuration/IdentityProviderFactory.test.ts @@ -129,7 +129,7 @@ describe('An IdentityProviderFactory', (): void => { // Test the renderError function const response = { } as HttpResponse; - await expect((config.renderError as any)({ res: response }, null, 'error!')).resolves.toBeUndefined(); + await expect((config.renderError as any)({ res: response }, {}, 'error!')).resolves.toBeUndefined(); expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1); expect(errorHandler.handleSafe) .toHaveBeenLastCalledWith({ error: 'error!', preferences: { type: { 'text/plain': 1 }}}); @@ -191,4 +191,22 @@ describe('An IdentityProviderFactory', (): void => { expect(storage.set).toHaveBeenCalledWith('jwks', result1.config.jwks); expect(storage.set).toHaveBeenCalledWith('cookie-secret', result1.config.cookies?.keys); }); + + it('updates errors if there is more information.', async(): Promise => { + const provider = await factory.getProvider() as any; + const { config } = provider as { config: Configuration }; + const response = { } as HttpResponse; + + const error = new Error('bad data'); + const out = { error_description: 'more info' }; + + await expect((config.renderError as any)({ res: response }, out, error)).resolves.toBeUndefined(); + expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(errorHandler.handleSafe) + .toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/plain': 1 }}}); + expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); + expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }}); + expect(error.message).toBe('bad data - more info'); + expect(error.stack).toContain('Error: bad data - more info'); + }); }); From e604c0c2e427f7cf426cda6e3a52c2d72b997057 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 16 Feb 2022 10:57:47 +0100 Subject: [PATCH 32/39] feat: Return client information from consent handler --- RELEASE_NOTES.md | 1 + src/identity/interaction/ConsentHandler.ts | 33 ++++++++++++++++++- .../identity/email-password/consent.html.ejs | 28 ++++++++++++++-- test/integration/Identity.test.ts | 19 ++++++++--- .../interaction/ConsentHandler.test.ts | 33 ++++++++++++++++++- 5 files changed, 105 insertions(+), 9 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e6a208d7f..18c71194d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -12,6 +12,7 @@ which enables passing custom variables to configurations and setting new default values. - The AppRunner functions have changed to require Components.js variables. This is important for anyone who starts the server from code. +- When logging in, a consent screen will now provide information about the client. ### Configuration changes You might need to make changes to your v2 configuration if you use a custom config. diff --git a/src/identity/interaction/ConsentHandler.ts b/src/identity/interaction/ConsentHandler.ts index a7cf11144..79e1df6c6 100644 --- a/src/identity/interaction/ConsentHandler.ts +++ b/src/identity/interaction/ConsentHandler.ts @@ -1,4 +1,12 @@ -import type { InteractionResults, KoaContextWithOIDC, UnknownObject } from 'oidc-provider'; +import type { + AllClientMetadata, + InteractionResults, + KoaContextWithOIDC, + UnknownObject, +} from 'oidc-provider'; +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; import { FoundHttpError } from '../../util/errors/FoundHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; @@ -11,6 +19,8 @@ type Grant = NonNullable; /** * Handles the OIDC consent prompts where the user confirms they want to log in for the given client. + * + * Returns all the relevant Client metadata on GET requests. */ export class ConsentHandler extends BaseInteractionHandler { private readonly providerFactory: ProviderFactory; @@ -30,6 +40,27 @@ export class ConsentHandler extends BaseInteractionHandler { } } + protected async handleGet(input: Required): Promise { + const { operation, oidcInteraction } = input; + const provider = await this.providerFactory.getProvider(); + const client = await provider.Client.find(oidcInteraction.params.client_id as string); + const metadata: AllClientMetadata = client?.metadata() ?? {}; + + // Only extract specific fields to prevent leaking information + // Based on https://www.w3.org/ns/solid/oidc-context.jsonld + const keys = [ 'client_id', 'client_uri', 'logo_uri', 'policy_uri', + 'client_name', 'contacts', 'grant_types', 'scope' ]; + + const jsonLd = Object.fromEntries( + keys.filter((key): boolean => key in metadata) + .map((key): [ string, unknown ] => [ key, metadata[key] ]), + ); + jsonLd['@context'] = 'https://www.w3.org/ns/solid/oidc-context.jsonld'; + const json = { client: jsonLd }; + + return new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON); + } + protected async handlePost({ operation, oidcInteraction }: InteractionHandlerInput): Promise { const { remember } = await readJsonStream(operation.body.data); diff --git a/templates/identity/email-password/consent.html.ejs b/templates/identity/email-password/consent.html.ejs index cd3edcbce..750f3999c 100644 --- a/templates/identity/email-password/consent.html.ejs +++ b/templates/identity/email-password/consent.html.ejs @@ -1,19 +1,41 @@

    Authorize

    -

    You are authorizing an application to access your Pod.

    +

    The following client wants to do authorized requests in your name:

    +
      +

    1. - +
    -

    +

    diff --git a/test/integration/Identity.test.ts b/test/integration/Identity.test.ts index 5e3ddacf0..6f258d48e 100644 --- a/test/integration/Identity.test.ts +++ b/test/integration/Identity.test.ts @@ -193,6 +193,12 @@ describe('A Solid server with IDP', (): void => { default_max_age: 3600, require_auth_time: true, }; + // This client will always reject requests since there is no valid redirect + const badClientJson = { + ...clientJson, + client_id: badClientId, + redirect_uris: [], + }; /* eslint-enable @typescript-eslint/naming-convention */ let state: IdentityTestState; @@ -205,13 +211,10 @@ describe('A Solid server with IDP', (): void => { body: JSON.stringify(clientJson), }); - // This client will always reject requests since there is no valid redirect - clientJson.client_id = badClientId; - clientJson.redirect_uris = []; await fetch(badClientId, { method: 'PUT', headers: { 'content-type': 'application/ld+json' }, - body: JSON.stringify(clientJson), + body: JSON.stringify(badClientJson), }); }); @@ -224,6 +227,14 @@ describe('A Solid server with IDP', (): void => { const res = await state.fetchIdp(url); expect(res.status).toBe(200); url = await state.login(url, email, password); + + // Verify the client information the server discovered + const consentRes = await state.fetchIdp(url, 'GET'); + expect(consentRes.status).toBe(200); + const { client } = await consentRes.json(); + expect(client.client_id).toBe(clientJson.client_id); + expect(client.client_name).toBe(clientJson.client_name); + await state.consent(url); expect(state.session.info?.webId).toBe(webId); }); diff --git a/test/unit/identity/interaction/ConsentHandler.test.ts b/test/unit/identity/interaction/ConsentHandler.test.ts index 28cf92890..fbb8159e5 100644 --- a/test/unit/identity/interaction/ConsentHandler.test.ts +++ b/test/unit/identity/interaction/ConsentHandler.test.ts @@ -4,6 +4,7 @@ import { ConsentHandler } from '../../../../src/identity/interaction/ConsentHand import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler'; import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import { readJsonStream } from '../../../../src/util/StreamUtil'; import { createPostJsonOperation } from './email-password/handler/Util'; const newGrantId = 'newGrantId'; @@ -45,6 +46,10 @@ class DummyGrant { describe('A ConsentHandler', (): void => { const accountId = 'http://example.com/id#me'; const clientId = 'clientId'; + const clientMetadata = { + // eslint-disable-next-line @typescript-eslint/naming-convention + client_id: 'clientId', + }; let grantFn: jest.Mock & { find: jest.Mock }; let knownGrant: DummyGrant; let oidcInteraction: Interaction; @@ -66,8 +71,12 @@ describe('A ConsentHandler', (): void => { grantFn = jest.fn((props): DummyGrant => new DummyGrant(props)) as any; grantFn.find = jest.fn((grantId: string): any => grantId ? knownGrant : undefined); provider = { - // eslint-disable-next-line @typescript-eslint/naming-convention + /* eslint-disable @typescript-eslint/naming-convention */ Grant: grantFn, + Client: { + find: (id: string): any => (id ? { metadata: jest.fn().mockReturnValue(clientMetadata) } : undefined), + }, + /* eslint-enable @typescript-eslint/naming-convention */ } as any; providerFactory = { @@ -89,6 +98,28 @@ describe('A ConsentHandler', (): void => { .resolves.toBeUndefined(); }); + it('returns the client metadata on a GET request.', async(): Promise => { + const operation = { method: 'GET', target: { path: 'http://example.com/foo' }} as any; + const representation = await handler.handle({ operation, oidcInteraction }); + await expect(readJsonStream(representation.data)).resolves.toEqual({ + client: { + ...clientMetadata, + '@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld', + }, + }); + }); + + it('returns an empty object if no client was found.', async(): Promise => { + delete oidcInteraction.params.client_id; + const operation = { method: 'GET', target: { path: 'http://example.com/foo' }} as any; + const representation = await handler.handle({ operation, oidcInteraction }); + await expect(readJsonStream(representation.data)).resolves.toEqual({ + client: { + '@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld', + }, + }); + }); + it('requires an oidcInteraction with a defined session.', async(): Promise => { oidcInteraction.session = undefined; await expect(handler.handle({ operation: createPostJsonOperation({}), oidcInteraction })) From 62e22100238f1b9dfb13b9f350fccf12184f728b Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Thu, 17 Feb 2022 10:59:10 +0100 Subject: [PATCH 33/39] feat: Warn users when they change the base URL --- config/app/init/base/init.json | 2 ++ config/app/init/initializers/base-url.json | 13 ++++++++ src/index.ts | 1 + src/init/BaseUrlVerifier.ts | 32 ++++++++++++++++++ test/unit/init/BaseUrlVerifier.test.ts | 38 ++++++++++++++++++++++ 5 files changed, 86 insertions(+) create mode 100644 config/app/init/initializers/base-url.json create mode 100644 src/init/BaseUrlVerifier.ts create mode 100644 test/unit/init/BaseUrlVerifier.test.ts diff --git a/config/app/init/base/init.json b/config/app/init/base/init.json index d794509f0..b19126bd6 100644 --- a/config/app/init/base/init.json +++ b/config/app/init/base/init.json @@ -1,6 +1,7 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", "import": [ + "files-scs:config/app/init/initializers/base-url.json", "files-scs:config/app/init/initializers/logger.json", "files-scs:config/app/init/initializers/server.json" ], @@ -11,6 +12,7 @@ "@type": "SequenceHandler", "handlers": [ { "@id": "urn:solid-server:default:LoggerInitializer" }, + { "@id": "urn:solid-server:default:BaseUrlVerifier" }, { "@id": "urn:solid-server:default:ParallelInitializer" }, { "@id": "urn:solid-server:default:ServerInitializer" } ] diff --git a/config/app/init/initializers/base-url.json b/config/app/init/initializers/base-url.json new file mode 100644 index 000000000..0eb1176dd --- /dev/null +++ b/config/app/init/initializers/base-url.json @@ -0,0 +1,13 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Logs a warning if the base URL changes.", + "@id": "urn:solid-server:default:BaseUrlVerifier", + "@type": "BaseUrlVerifier", + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "storageKey": "current-base-url", + "storage": { "@id": "urn:solid-server:default:SetupStorage" } + } + ] +} diff --git a/src/index.ts b/src/index.ts index 47587d80e..899275ed8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -201,6 +201,7 @@ export * from './init/variables/SettingsResolver'; // Init export * from './init/App'; export * from './init/AppRunner'; +export * from './init/BaseUrlVerifier'; export * from './init/CliResolver'; export * from './init/ConfigPodInitializer'; export * from './init/ContainerInitializer'; diff --git a/src/init/BaseUrlVerifier.ts b/src/init/BaseUrlVerifier.ts new file mode 100644 index 000000000..4b2b39b4d --- /dev/null +++ b/src/init/BaseUrlVerifier.ts @@ -0,0 +1,32 @@ +import { getLoggerFor } from '../logging/LogUtil'; +import type { KeyValueStorage } from '../storage/keyvalue/KeyValueStorage'; +import { Initializer } from './Initializer'; + +/** + * Stores the `baseUrl` value that was used to start the server + * and warns the user in case it differs from the previous one. + */ +export class BaseUrlVerifier extends Initializer { + private readonly baseUrl: string; + private readonly storageKey: string; + private readonly storage: KeyValueStorage; + + private readonly logger = getLoggerFor(this); + + public constructor(baseUrl: string, storageKey: string, storage: KeyValueStorage) { + super(); + this.baseUrl = baseUrl; + this.storageKey = storageKey; + this.storage = storage; + } + + public async handle(): Promise { + const previousValue = await this.storage.get(this.storageKey); + if (previousValue && this.baseUrl !== previousValue) { + this.logger.warn(`The server is being started with a base URL of ${this.baseUrl + } while it was previously started with ${previousValue + }. Resources generated with the previous server instance, such as a WebID, might no longer work correctly.`); + } + await this.storage.set(this.storageKey, this.baseUrl); + } +} diff --git a/test/unit/init/BaseUrlVerifier.test.ts b/test/unit/init/BaseUrlVerifier.test.ts new file mode 100644 index 000000000..a669caf6a --- /dev/null +++ b/test/unit/init/BaseUrlVerifier.test.ts @@ -0,0 +1,38 @@ +import { BaseUrlVerifier } from '../../../src/init/BaseUrlVerifier'; +import type { Logger } from '../../../src/logging/Logger'; +import { getLoggerFor } from '../../../src/logging/LogUtil'; +import type { KeyValueStorage } from '../../../src/storage/keyvalue/KeyValueStorage'; + +jest.mock('../../../src/logging/LogUtil', (): any => { + const logger: Logger = { warn: jest.fn() } as any; + return { getLoggerFor: (): Logger => logger }; +}); + +describe('A BaseUrlVerifier', (): void => { + const logger: jest.Mocked = getLoggerFor(BaseUrlVerifier) as any; + const baseUrl1 = 'http://base1.example.com/'; + const baseUrl2 = 'http://base2.example.com/'; + const storageKey = 'uniqueKey'; + let storage: KeyValueStorage; + + beforeEach(async(): Promise => { + storage = new Map() as any; + jest.clearAllMocks(); + }); + + it('stores the value if no value was stored yet.', async(): Promise => { + const initializer = new BaseUrlVerifier(baseUrl1, storageKey, storage); + await expect(initializer.handle()).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalledTimes(0); + }); + + it('logs a warning in case the value changes.', async(): Promise => { + let initializer = new BaseUrlVerifier(baseUrl1, storageKey, storage); + await expect(initializer.handle()).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalledTimes(0); + + initializer = new BaseUrlVerifier(baseUrl2, storageKey, storage); + await expect(initializer.handle()).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); +}); From 2dc20fe3bc63da1d0a39720410da07f316b253ac Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Thu, 17 Feb 2022 11:30:48 +0100 Subject: [PATCH 34/39] feat: Store the server version on start --- config/app/init/base/init.json | 6 ++-- config/app/init/initializers/version.json | 12 ++++++++ package-lock.json | 24 ++++------------ package.json | 4 +-- src/index.ts | 1 + src/init/ModuleVersionVerifier.ts | 30 ++++++++++++++++++++ test/unit/init/ModuleVersionVerifier.test.ts | 17 +++++++++++ 7 files changed, 72 insertions(+), 22 deletions(-) create mode 100644 config/app/init/initializers/version.json create mode 100644 src/init/ModuleVersionVerifier.ts create mode 100644 test/unit/init/ModuleVersionVerifier.test.ts diff --git a/config/app/init/base/init.json b/config/app/init/base/init.json index b19126bd6..f3fad40d1 100644 --- a/config/app/init/base/init.json +++ b/config/app/init/base/init.json @@ -3,7 +3,8 @@ "import": [ "files-scs:config/app/init/initializers/base-url.json", "files-scs:config/app/init/initializers/logger.json", - "files-scs:config/app/init/initializers/server.json" + "files-scs:config/app/init/initializers/server.json", + "files-scs:config/app/init/initializers/version.json" ], "@graph": [ { @@ -14,7 +15,8 @@ { "@id": "urn:solid-server:default:LoggerInitializer" }, { "@id": "urn:solid-server:default:BaseUrlVerifier" }, { "@id": "urn:solid-server:default:ParallelInitializer" }, - { "@id": "urn:solid-server:default:ServerInitializer" } + { "@id": "urn:solid-server:default:ServerInitializer" }, + { "@id": "urn:solid-server:default:ModuleVersionVerifier" } ] } ] diff --git a/config/app/init/initializers/version.json b/config/app/init/initializers/version.json new file mode 100644 index 000000000..ad06d3d1f --- /dev/null +++ b/config/app/init/initializers/version.json @@ -0,0 +1,12 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Logs a warning if the base URL changes.", + "@id": "urn:solid-server:default:ModuleVersionVerifier", + "@type": "ModuleVersionVerifier", + "storageKey": "current-server-version", + "storage": { "@id": "urn:solid-server:default:SetupStorage" } + } + ] +} diff --git a/package-lock.json b/package-lock.json index 34e7d8db0..7c54daae0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@types/bcrypt": "^5.0.0", "@types/cors": "^2.8.12", "@types/end-of-stream": "^1.4.1", + "@types/fs-extra": "^9.0.13", "@types/lodash.orderby": "^4.6.6", "@types/marked": "^4.0.2", "@types/mime-types": "^2.1.1", @@ -42,6 +43,7 @@ "end-of-stream": "^1.4.4", "escape-string-regexp": "^4.0.0", "fetch-sparql-endpoint": "^2.4.0", + "fs-extra": "^10.0.0", "handlebars": "^4.7.7", "jose": "^4.4.0", "lodash.orderby": "^4.6.0", @@ -76,7 +78,6 @@ "@tsconfig/node12": "^1.0.9", "@types/cheerio": "^0.22.30", "@types/ejs": "^3.1.0", - "@types/fs-extra": "^9.0.13", "@types/jest": "^27.4.0", "@types/set-cookie-parser": "^2.4.2", "@types/supertest": "^2.0.11", @@ -91,7 +92,6 @@ "eslint-plugin-jest": "^26.0.0", "eslint-plugin-tsdoc": "^0.2.14", "eslint-plugin-unused-imports": "^2.0.0", - "fs-extra": "^10.0.0", "husky": "^4.3.8", "jest": "^27.4.7", "jest-rdf": "^1.7.0", @@ -4430,7 +4430,6 @@ "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -8310,7 +8309,6 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.0.tgz", "integrity": "sha512-C5owb14u9eJwizKGdchcDUQeFtlSHHthBk8pbX9Vc1PFZrLombudjDnNns88aYslCyF6IY5SUw3Roz6xShcEIQ==", - "dev": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -8324,7 +8322,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "dev": true, "engines": { "node": ">= 10.0.0" } @@ -8907,8 +8904,7 @@ "node_modules/graceful-fs": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==" }, "node_modules/graphql": { "version": "15.8.0", @@ -10858,7 +10854,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, "dependencies": { "universalify": "^2.0.0" }, @@ -10870,7 +10865,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "dev": true, "engines": { "node": ">= 10.0.0" } @@ -18559,7 +18553,6 @@ "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", - "dev": true, "requires": { "@types/node": "*" } @@ -21542,7 +21535,6 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.0.tgz", "integrity": "sha512-C5owb14u9eJwizKGdchcDUQeFtlSHHthBk8pbX9Vc1PFZrLombudjDnNns88aYslCyF6IY5SUw3Roz6xShcEIQ==", - "dev": true, "requires": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -21552,8 +21544,7 @@ "universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "dev": true + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==" } } }, @@ -21986,8 +21977,7 @@ "graceful-fs": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==" }, "graphql": { "version": "15.8.0", @@ -23445,7 +23435,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, "requires": { "graceful-fs": "^4.1.6", "universalify": "^2.0.0" @@ -23454,8 +23443,7 @@ "universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "dev": true + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==" } } }, diff --git a/package.json b/package.json index 814bbc0ce..9f0cfa30c 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@types/bcrypt": "^5.0.0", "@types/cors": "^2.8.12", "@types/end-of-stream": "^1.4.1", + "@types/fs-extra": "^9.0.13", "@types/lodash.orderby": "^4.6.6", "@types/marked": "^4.0.2", "@types/mime-types": "^2.1.1", @@ -108,6 +109,7 @@ "end-of-stream": "^1.4.4", "escape-string-regexp": "^4.0.0", "fetch-sparql-endpoint": "^2.4.0", + "fs-extra": "^10.0.0", "handlebars": "^4.7.7", "jose": "^4.4.0", "lodash.orderby": "^4.6.0", @@ -139,7 +141,6 @@ "@tsconfig/node12": "^1.0.9", "@types/cheerio": "^0.22.30", "@types/ejs": "^3.1.0", - "@types/fs-extra": "^9.0.13", "@types/jest": "^27.4.0", "@types/set-cookie-parser": "^2.4.2", "@types/supertest": "^2.0.11", @@ -154,7 +155,6 @@ "eslint-plugin-jest": "^26.0.0", "eslint-plugin-tsdoc": "^0.2.14", "eslint-plugin-unused-imports": "^2.0.0", - "fs-extra": "^10.0.0", "husky": "^4.3.8", "jest": "^27.4.7", "jest-rdf": "^1.7.0", diff --git a/src/index.ts b/src/index.ts index 899275ed8..86f2cf068 100644 --- a/src/index.ts +++ b/src/index.ts @@ -208,6 +208,7 @@ export * from './init/ContainerInitializer'; export * from './init/Initializer'; export * from './init/LoggerInitializer'; export * from './init/ServerInitializer'; +export * from './init/ModuleVersionVerifier'; // Logging export * from './logging/LazyLogger'; diff --git a/src/init/ModuleVersionVerifier.ts b/src/init/ModuleVersionVerifier.ts new file mode 100644 index 000000000..37039efd7 --- /dev/null +++ b/src/init/ModuleVersionVerifier.ts @@ -0,0 +1,30 @@ +import { readJson } from 'fs-extra'; +import type { KeyValueStorage } from '../storage/keyvalue/KeyValueStorage'; +import { modulePathPlaceholder, resolveAssetPath } from '../util/PathUtil'; +import { Initializer } from './Initializer'; + +const PACKAGE_JSON_PATH = `${modulePathPlaceholder}package.json`; + +/** + * This initializer simply writes the version number of the server to the storage. + * This will be relevant in the future when we look into migration initializers. + * + * It automatically parses the version number from the `package.json`. + */ +export class ModuleVersionVerifier extends Initializer { + private readonly storageKey: string; + private readonly storage: KeyValueStorage; + + public constructor(storageKey: string, storage: KeyValueStorage) { + super(); + this.storageKey = storageKey; + this.storage = storage; + } + + public async handle(): Promise { + const path = resolveAssetPath(PACKAGE_JSON_PATH); + const pkg = await readJson(path); + + await this.storage.set(this.storageKey, pkg.version); + } +} diff --git a/test/unit/init/ModuleVersionVerifier.test.ts b/test/unit/init/ModuleVersionVerifier.test.ts new file mode 100644 index 000000000..f524c0340 --- /dev/null +++ b/test/unit/init/ModuleVersionVerifier.test.ts @@ -0,0 +1,17 @@ +import { ModuleVersionVerifier } from '../../../src/init/ModuleVersionVerifier'; + +describe('A ModuleVersionVerifier', (): void => { + const storageKey = 'uniqueVersionKey'; + let storageMap: Map; + let initializer: ModuleVersionVerifier; + + beforeEach(async(): Promise => { + storageMap = new Map(); + initializer = new ModuleVersionVerifier(storageKey, storageMap as any); + }); + + it('stores the latest version.', async(): Promise => { + await expect(initializer.handle()).resolves.toBeUndefined(); + expect(storageMap.get(storageKey)).toMatch(/^\d+\.\d+\.\d+(?:-.+)?/u); + }); +}); From 63060901144c3c8c45704195268face139534e53 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Fri, 18 Feb 2022 09:55:54 +0100 Subject: [PATCH 35/39] refactor: Add resolveModulePath. --- src/init/AppRunner.ts | 4 +- src/init/ModuleVersionVerifier.ts | 8 +- .../conversion/ErrorToTemplateConverter.ts | 6 +- src/util/PathUtil.ts | 21 ++++- .../ErrorToTemplateConverter.test.ts | 5 +- test/unit/util/PathUtil.test.ts | 78 ++++++++++++------- 6 files changed, 79 insertions(+), 43 deletions(-) diff --git a/src/init/AppRunner.ts b/src/init/AppRunner.ts index 39b4913a7..5e2d77677 100644 --- a/src/init/AppRunner.ts +++ b/src/init/AppRunner.ts @@ -6,12 +6,12 @@ import yargs from 'yargs'; import { LOG_LEVELS } from '../logging/LogLevel'; import { getLoggerFor } from '../logging/LogUtil'; import { createErrorMessage, isError } from '../util/errors/ErrorUtil'; -import { modulePathPlaceholder, resolveAssetPath } from '../util/PathUtil'; +import { resolveModulePath, resolveAssetPath } from '../util/PathUtil'; import type { App } from './App'; import type { CliResolver } from './CliResolver'; import type { CliArgv, VariableBindings } from './variables/Types'; -const DEFAULT_CONFIG = `${modulePathPlaceholder}config/default.json`; +const DEFAULT_CONFIG = resolveModulePath('config/default.json'); const DEFAULT_CLI_RESOLVER = 'urn:solid-server-app-setup:default:CliResolver'; const DEFAULT_APP = 'urn:solid-server:default:App'; diff --git a/src/init/ModuleVersionVerifier.ts b/src/init/ModuleVersionVerifier.ts index 37039efd7..dd17922de 100644 --- a/src/init/ModuleVersionVerifier.ts +++ b/src/init/ModuleVersionVerifier.ts @@ -1,9 +1,9 @@ import { readJson } from 'fs-extra'; import type { KeyValueStorage } from '../storage/keyvalue/KeyValueStorage'; -import { modulePathPlaceholder, resolveAssetPath } from '../util/PathUtil'; +import { resolveModulePath } from '../util/PathUtil'; import { Initializer } from './Initializer'; -const PACKAGE_JSON_PATH = `${modulePathPlaceholder}package.json`; +const PACKAGE_JSON_PATH = resolveModulePath('package.json'); /** * This initializer simply writes the version number of the server to the storage. @@ -22,9 +22,7 @@ export class ModuleVersionVerifier extends Initializer { } public async handle(): Promise { - const path = resolveAssetPath(PACKAGE_JSON_PATH); - const pkg = await readJson(path); - + const pkg = await readJson(PACKAGE_JSON_PATH); await this.storage.set(this.storageKey, pkg.version); } } diff --git a/src/storage/conversion/ErrorToTemplateConverter.ts b/src/storage/conversion/ErrorToTemplateConverter.ts index 563668a54..48aa23357 100644 --- a/src/storage/conversion/ErrorToTemplateConverter.ts +++ b/src/storage/conversion/ErrorToTemplateConverter.ts @@ -3,7 +3,7 @@ import { BasicRepresentation } from '../../http/representation/BasicRepresentati import type { Representation } from '../../http/representation/Representation'; import { INTERNAL_ERROR } from '../../util/ContentTypes'; import { HttpError } from '../../util/errors/HttpError'; -import { modulePathPlaceholder } from '../../util/PathUtil'; +import { resolveModulePath } from '../../util/PathUtil'; import { getSingleItem } from '../../util/StreamUtil'; import type { TemplateEngine } from '../../util/templates/TemplateEngine'; import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter'; @@ -18,8 +18,8 @@ export interface TemplateOptions { } const DEFAULT_TEMPLATE_OPTIONS: TemplateOptions = { - mainTemplatePath: `${modulePathPlaceholder}templates/error/main.md.hbs`, - codeTemplatesPath: `${modulePathPlaceholder}templates/error/descriptions/`, + mainTemplatePath: resolveModulePath('templates/error/main.md.hbs'), + codeTemplatesPath: resolveModulePath('templates/error/descriptions/'), extension: '.md.hbs', contentType: 'text/markdown', }; diff --git a/src/util/PathUtil.ts b/src/util/PathUtil.ts index c5078e4eb..29a1520d8 100644 --- a/src/util/PathUtil.ts +++ b/src/util/PathUtil.ts @@ -197,18 +197,33 @@ export function getModuleRoot(): string { /** * A placeholder for the path to the `@solid/community-server` module root. - * The resolveAssetPath function will replace this string with the actual path. + * The `resolveAssetPath` function will replace this string with the actual path. */ export const modulePathPlaceholder = '@css:'; +/** + * Creates a path starting from the `@solid/community-server` module root, + * to be resolved by the `resolveAssetPath` function. + */ +export function modulePath(relativePath = ''): string { + return `${modulePathPlaceholder}${relativePath}`; +} + +/** + * Creates an absolute path starting from the `@solid/community-server` module root. + */ +export function resolveModulePath(relativePath = ''): string { + return joinFilePath(getModuleRoot(), relativePath); +} + /** * Converts file path inputs into absolute paths. * Works similar to `absoluteFilePath` but paths that start with the `modulePathPlaceholder` * will be relative to the module directory instead of the cwd. */ -export function resolveAssetPath(path: string = modulePathPlaceholder): string { +export function resolveAssetPath(path = modulePathPlaceholder): string { if (path.startsWith(modulePathPlaceholder)) { - return joinFilePath(getModuleRoot(), path.slice(modulePathPlaceholder.length)); + return resolveModulePath(path.slice(modulePathPlaceholder.length)); } return absoluteFilePath(path); } diff --git a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts index 27f106859..31e190fcb 100644 --- a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts +++ b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts @@ -1,6 +1,7 @@ import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import { ErrorToTemplateConverter } from '../../../../src/storage/conversion/ErrorToTemplateConverter'; import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; +import { resolveModulePath } from '../../../../src/util/PathUtil'; import { readableToString } from '../../../../src/util/StreamUtil'; import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; @@ -155,9 +156,9 @@ describe('An ErrorToTemplateConverter', (): void => { expect(templateEngine.render).toHaveBeenCalledTimes(2); expect(templateEngine.render).toHaveBeenNthCalledWith(1, { key: 'val' }, - { templatePath: '@css:templates/error/descriptions/', templateFile: 'E0001.md.hbs' }); + { templatePath: resolveModulePath('templates/error/descriptions/'), templateFile: 'E0001.md.hbs' }); expect(templateEngine.render).toHaveBeenNthCalledWith(2, { name: 'BadRequestHttpError', message: 'error text', stack: error.stack, description: '' }, - { templateFile: '@css:templates/error/main.md.hbs' }); + { templateFile: resolveModulePath('templates/error/main.md.hbs') }); }); }); diff --git a/test/unit/util/PathUtil.test.ts b/test/unit/util/PathUtil.test.ts index 0d9e3c7cc..d809d20b0 100644 --- a/test/unit/util/PathUtil.test.ts +++ b/test/unit/util/PathUtil.test.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'fs'; +import { promises as fsPromises } from 'fs'; import type { TargetExtractor } from '../../../src/http/input/identifier/TargetExtractor'; import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; import type { HttpRequest } from '../../../src/server/HttpRequest'; @@ -16,49 +16,51 @@ import { isContainerPath, joinFilePath, joinUrl, + modulePath, normalizeFilePath, resolveAssetPath, + resolveModulePath, toCanonicalUriPath, trimTrailingSlashes, } from '../../../src/util/PathUtil'; describe('PathUtil', (): void => { describe('#normalizeFilePath', (): void => { - it('normalizes POSIX paths.', async(): Promise => { + it('normalizes POSIX paths.', (): void => { expect(normalizeFilePath('/foo/bar/../baz')).toBe('/foo/baz'); }); - it('normalizes Windows paths.', async(): Promise => { + it('normalizes Windows paths.', (): void => { expect(normalizeFilePath('c:\\foo\\bar\\..\\baz')).toBe('c:/foo/baz'); }); }); describe('#joinFilePath', (): void => { - it('joins POSIX paths.', async(): Promise => { + it('joins POSIX paths.', (): void => { expect(joinFilePath('/foo/bar/', '..', '/baz')).toBe('/foo/baz'); }); - it('joins Windows paths.', async(): Promise => { + it('joins Windows paths.', (): void => { expect(joinFilePath('c:\\foo\\bar\\', '..', '/baz')).toBe(`c:/foo/baz`); }); }); describe('#absoluteFilePath', (): void => { - it('does not change absolute posix paths.', async(): Promise => { + it('does not change absolute posix paths.', (): void => { expect(absoluteFilePath('/foo/bar/')).toBe('/foo/bar/'); }); - it('converts absolute win32 paths to posix paths.', async(): Promise => { + it('converts absolute win32 paths to posix paths.', (): void => { expect(absoluteFilePath('C:\\foo\\bar')).toBe('C:/foo/bar'); }); - it('makes relative paths absolute.', async(): Promise => { + it('makes relative paths absolute.', (): void => { expect(absoluteFilePath('foo/bar/')).toEqual(joinFilePath(process.cwd(), 'foo/bar/')); }); }); describe('#ensureTrailingSlash', (): void => { - it('makes sure there is always exactly 1 slash.', async(): Promise => { + it('makes sure there is always exactly 1 slash.', (): void => { expect(ensureTrailingSlash('http://test.com')).toBe('http://test.com/'); expect(ensureTrailingSlash('http://test.com/')).toBe('http://test.com/'); expect(ensureTrailingSlash('http://test.com//')).toBe('http://test.com/'); @@ -67,7 +69,7 @@ describe('PathUtil', (): void => { }); describe('#trimTrailingSlashes', (): void => { - it('removes all trailing slashes.', async(): Promise => { + it('removes all trailing slashes.', (): void => { expect(trimTrailingSlashes('http://test.com')).toBe('http://test.com'); expect(trimTrailingSlashes('http://test.com/')).toBe('http://test.com'); expect(trimTrailingSlashes('http://test.com//')).toBe('http://test.com'); @@ -76,58 +78,58 @@ describe('PathUtil', (): void => { }); describe('#getExtension', (): void => { - it('returns the extension of a path.', async(): Promise => { + it('returns the extension of a path.', (): void => { expect(getExtension('/a/b.txt')).toBe('txt'); expect(getExtension('/a/btxt')).toBe(''); }); }); describe('#toCanonicalUriPath', (): void => { - it('encodes only the necessary parts.', async(): Promise => { + it('encodes only the necessary parts.', (): void => { expect(toCanonicalUriPath('/a%20path&/name')).toBe('/a%20path%26/name'); }); - it('leaves the query string untouched.', async(): Promise => { + it('leaves the query string untouched.', (): void => { expect(toCanonicalUriPath('/a%20path&/name?abc=def&xyz')).toBe('/a%20path%26/name?abc=def&xyz'); }); }); describe('#decodeUriPathComponents', (): void => { - it('decodes all parts of a path.', async(): Promise => { + it('decodes all parts of a path.', (): void => { expect(decodeUriPathComponents('/a%20path&/name')).toBe('/a path&/name'); }); - it('leaves the query string untouched.', async(): Promise => { + it('leaves the query string untouched.', (): void => { expect(decodeUriPathComponents('/a%20path&/name?abc=def&xyz')).toBe('/a path&/name?abc=def&xyz'); }); }); describe('#encodeUriPathComponents', (): void => { - it('encodes all parts of a path.', async(): Promise => { + it('encodes all parts of a path.', (): void => { expect(encodeUriPathComponents('/a%20path&/name')).toBe('/a%2520path%26/name'); }); - it('leaves the query string untouched.', async(): Promise => { + it('leaves the query string untouched.', (): void => { expect(encodeUriPathComponents('/a%20path&/name?abc=def&xyz')).toBe('/a%2520path%26/name?abc=def&xyz'); }); }); describe('#isContainerPath', (): void => { - it('returns true if the path ends with a slash.', async(): Promise => { + it('returns true if the path ends with a slash.', (): void => { expect(isContainerPath('/a/b')).toBe(false); expect(isContainerPath('/a/b/')).toBe(true); }); }); describe('#isContainerIdentifier', (): void => { - it('works af isContainerPath but for identifiers.', async(): Promise => { + it('works af isContainerPath but for identifiers.', (): void => { expect(isContainerIdentifier({ path: '/a/b' })).toBe(false); expect(isContainerIdentifier({ path: '/a/b/' })).toBe(true); }); }); describe('#extractScheme', (): void => { - it('splits a URL.', async(): Promise => { + it('splits a URL.', (): void => { expect(extractScheme('http://test.com/foo')).toEqual({ scheme: 'http://', rest: 'test.com/foo' }); }); }); @@ -155,7 +157,7 @@ describe('PathUtil', (): void => { }); describe('#createSubdomainRegexp', (): void => { - it('creates a regex to match the URL and extract a subdomain.', async(): Promise => { + it('creates a regex to match the URL and extract a subdomain.', (): void => { const regex = createSubdomainRegexp('http://test.com/foo/'); expect(regex.exec('http://test.com/foo/')![1]).toBeUndefined(); expect(regex.exec('http://test.com/foo/bar')![1]).toBeUndefined(); @@ -171,28 +173,48 @@ describe('PathUtil', (): void => { // Note that this test only makes sense as long as the dist folder is on the same level as the src folder const root = getModuleRoot(); const packageJson = joinFilePath(root, 'package.json'); - expect(existsSync(packageJson)).toBe(true); + expect(await fsPromises.access(packageJson)).toBeUndefined(); }); }); - describe('#resolvePathInput', (): void => { - it('interprets paths relative to the module root when starting with @css:.', async(): Promise => { + describe('#modulePath', (): void => { + it('transforms the empty input into "@css:".', (): void => { + expect(modulePath()).toBe('@css:'); + }); + + it('prefixes a path with "@css".', (): void => { + expect(modulePath('foo/bar.json')).toBe('@css:foo/bar.json'); + }); + }); + + describe('#resolveModulePath', (): void => { + it('transforms the empty input into the module root path.', (): void => { + expect(resolveModulePath()).toBe(getModuleRoot()); + }); + + it('prefixes a path with the module root path.', (): void => { + expect(resolveModulePath('foo/bar.json')).toBe(`${getModuleRoot()}foo/bar.json`); + }); + }); + + describe('#resolveAssetPath', (): void => { + it('interprets paths relative to the module root when starting with "@css:".', (): void => { expect(resolveAssetPath('@css:foo/bar')).toBe(joinFilePath(getModuleRoot(), '/foo/bar')); }); - it('handles ../ paths with @css:.', async(): Promise => { + it('handles ../ paths with "@css":.', (): void => { expect(resolveAssetPath('@css:foo/bar/../baz')).toBe(joinFilePath(getModuleRoot(), '/foo/baz')); }); - it('leaves absolute paths as they are.', async(): Promise => { + it('leaves absolute paths as they are.', (): void => { expect(resolveAssetPath('/foo/bar/')).toBe('/foo/bar/'); }); - it('handles other paths relative to the cwd.', async(): Promise => { + it('handles other paths relative to the cwd.', (): void => { expect(resolveAssetPath('foo/bar/')).toBe(joinFilePath(process.cwd(), 'foo/bar/')); }); - it('handles other paths with ../.', async(): Promise => { + it('handles other paths with ../.', (): void => { expect(resolveAssetPath('foo/bar/../baz')).toBe(joinFilePath(process.cwd(), 'foo/baz')); }); }); From f3e23ce66782c417f989e88b66a9e6180090c5c2 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 22 Feb 2022 13:38:06 +0100 Subject: [PATCH 36/39] docs: Mention context update in RELEASE_NOTES.md --- RELEASE_NOTES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 18c71194d..bff3d1309 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -17,6 +17,9 @@ ### Configuration changes You might need to make changes to your v2 configuration if you use a custom config. +The `@context` needs to be updated to +`https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld`. + The following changes pertain to the imports in the default configs: - A new configuration option needs to be imported: - `/app/variables/default/json` contains everything related to parsing CLI arguments From eceb71088ac2790900e6143ab23519f0d42925e7 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Wed, 23 Feb 2022 10:40:14 +0100 Subject: [PATCH 37/39] docs: Explain why IDP redirects are transformed into JSON responses. --- .../identity/handler/interaction/routes.json | 2 +- .../interaction/LocationInteractionHandler.ts | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/config/identity/handler/interaction/routes.json b/config/identity/handler/interaction/routes.json index a15a177f9..dd6ccf59b 100644 --- a/config/identity/handler/interaction/routes.json +++ b/config/identity/handler/interaction/routes.json @@ -27,7 +27,7 @@ ] }, { - "comment": "Converts redirect errors to location JSON responses.", + "comment": "Converts 3xx redirects to 200 JSON responses for consumption by browser scripts.", "@id": "urn:solid-server:auth:password:LocationInteractionHandler", "@type": "LocationInteractionHandler", "LocationInteractionHandler:_source" : { "@id": "urn:solid-server:auth:password:InteractionRouteHandler" } diff --git a/src/identity/interaction/LocationInteractionHandler.ts b/src/identity/interaction/LocationInteractionHandler.ts index 253e1f357..392307139 100644 --- a/src/identity/interaction/LocationInteractionHandler.ts +++ b/src/identity/interaction/LocationInteractionHandler.ts @@ -6,13 +6,21 @@ import type { InteractionHandlerInput } from './InteractionHandler'; import { InteractionHandler } from './InteractionHandler'; /** - * Catches redirect errors from the source and returns a JSON body containing a `location` field instead. - * This allows the API to be used more easily from the browser. + * Transforms an HTTP redirect into a hypermedia document with a redirection link, + * such that scripts running in a browser can redirect the user to the next page. * - * The issue is that if the API actually did a redirect, - * this would make it unusable when using it on HTML pages that need to render errors in case the fetch fails, - * but want to redirect the page in case it succeeds. - * See full overview at https://github.com/solid/community-server/pull/1088. + * This handler addresses the situation where: + * - the user visits a first page + * - this first page contains a script that performs interactions with a JSON API + * - as a result of a certain interaction, the user needs to be redirected to a second page + * + * Regular HTTP redirects are performed via responses with 3xx status codes. + * However, since the consumer of the API in this case is a browser script, + * a 3xx response would only reach that script and not move the page for the user. + * + * Therefore, this handler changes a 3xx response into a 200 response + * with an explicit link to the next page, + * enabling the script to move the user to the next page. */ export class LocationInteractionHandler extends InteractionHandler { private readonly source: InteractionHandler; From b292cd2e2bc1f319de4575173b8a1d63adb4cc41 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 23 Feb 2022 10:45:06 +0100 Subject: [PATCH 38/39] chore: Update configs to v3.0.0 --- config/app/init/base/init.json | 2 +- config/app/init/default.json | 2 +- config/app/init/initialize-prefilled-root.json | 2 +- config/app/init/initialize-root.json | 2 +- config/app/init/initializers/base-url.json | 2 +- config/app/init/initializers/logger.json | 2 +- config/app/init/initializers/prefilled-root.json | 2 +- config/app/init/initializers/root.json | 2 +- config/app/init/initializers/server.json | 2 +- config/app/init/initializers/version.json | 2 +- config/app/main/default.json | 2 +- config/app/setup/disabled.json | 2 +- config/app/setup/handlers/redirect.json | 2 +- config/app/setup/handlers/setup.json | 2 +- config/app/setup/optional.json | 2 +- config/app/setup/required.json | 2 +- config/app/variables/cli/cli.json | 2 +- config/app/variables/default.json | 2 +- config/app/variables/resolver/resolver.json | 2 +- config/default.json | 2 +- config/dynamic.json | 2 +- config/example-https-file.json | 2 +- config/file-no-setup.json | 2 +- config/file.json | 2 +- config/http/handler/default.json | 2 +- config/http/handler/handlers/oidc.json | 2 +- config/http/handler/simple.json | 2 +- config/http/middleware/handlers/constant-headers.json | 2 +- config/http/middleware/handlers/cors.json | 2 +- config/http/middleware/handlers/updates-via.json | 2 +- config/http/middleware/no-websockets.json | 2 +- config/http/middleware/websockets.json | 2 +- config/http/server-factory/https-example.json | 2 +- config/http/server-factory/no-websockets.json | 2 +- config/http/server-factory/websockets.json | 2 +- config/http/static/default.json | 2 +- config/identity/access/initializers/idp.json | 2 +- config/identity/access/initializers/well-known.json | 2 +- config/identity/access/public.json | 2 +- config/identity/access/restricted.json | 2 +- config/identity/email/default.json | 2 +- config/identity/email/example.json | 2 +- config/identity/handler/account-store/default.json | 2 +- config/identity/handler/adapter-factory/webid.json | 2 +- config/identity/handler/default.json | 2 +- config/identity/handler/interaction/routes.json | 2 +- .../identity/handler/interaction/routes/consent.json | 2 +- .../handler/interaction/routes/forgot-password.json | 2 +- config/identity/handler/interaction/routes/index.json | 2 +- config/identity/handler/interaction/routes/login.json | 2 +- config/identity/handler/interaction/routes/prompt.json | 2 +- .../handler/interaction/routes/reset-password.json | 2 +- .../identity/handler/interaction/views/controls.json | 2 +- config/identity/handler/interaction/views/html.json | 2 +- config/identity/handler/provider-factory/identity.json | 2 +- config/identity/ownership/token.json | 2 +- config/identity/ownership/unsafe-no-check.json | 2 +- config/identity/pod/dynamic.json | 2 +- config/identity/pod/pod-generators/templated.json | 2 +- config/identity/pod/resource-generators/templated.json | 2 +- config/identity/pod/static.json | 2 +- config/identity/registration/disabled.json | 2 +- config/identity/registration/enabled.json | 2 +- config/identity/registration/route/registration.json | 2 +- config/ldp/authentication/debug-auth-header.json | 2 +- config/ldp/authentication/debug-test-agent.json | 2 +- config/ldp/authentication/dpop-bearer.json | 2 +- config/ldp/authorization/allow-all.json | 2 +- .../readers/access-checkers/agent-class.json | 2 +- .../readers/access-checkers/agent-group.json | 2 +- .../authorization/readers/access-checkers/agent.json | 2 +- config/ldp/authorization/readers/acl.json | 2 +- config/ldp/authorization/readers/ownership.json | 2 +- config/ldp/authorization/webacl.json | 2 +- config/ldp/handler/components/authorizer.json | 2 +- config/ldp/handler/components/error-handler.json | 2 +- config/ldp/handler/components/operation-handler.json | 2 +- config/ldp/handler/components/operation-metadata.json | 2 +- config/ldp/handler/components/request-parser.json | 2 +- config/ldp/handler/components/response-writer.json | 2 +- config/ldp/handler/default.json | 2 +- config/ldp/metadata-parser/default.json | 2 +- config/ldp/metadata-parser/parsers/content-length.json | 2 +- config/ldp/metadata-parser/parsers/content-type.json | 2 +- config/ldp/metadata-parser/parsers/link.json | 2 +- config/ldp/metadata-parser/parsers/slug.json | 2 +- config/ldp/metadata-writer/default.json | 2 +- config/ldp/metadata-writer/writers/constant.json | 2 +- config/ldp/metadata-writer/writers/link-rel.json | 2 +- config/ldp/metadata-writer/writers/mapped.json | 2 +- config/ldp/metadata-writer/writers/modified.json | 2 +- config/ldp/metadata-writer/writers/wac-allow.json | 2 +- config/ldp/metadata-writer/writers/www-auth.json | 2 +- config/ldp/modes/default.json | 2 +- config/memory-subdomains.json | 2 +- config/path-routing.json | 2 +- config/quota-file.json | 2 +- config/restrict-idp.json | 2 +- config/sparql-endpoint-no-setup.json | 2 +- config/sparql-endpoint.json | 2 +- config/sparql-file-storage.json | 2 +- config/storage/backend/data-accessors/file.json | 2 +- config/storage/backend/data-accessors/memory.json | 2 +- .../backend/data-accessors/sparql-endpoint.json | 2 +- config/storage/backend/dynamic.json | 2 +- config/storage/backend/file.json | 2 +- config/storage/backend/global-quota-file.json | 2 +- config/storage/backend/memory.json | 2 +- config/storage/backend/pod-quota-file.json | 2 +- config/storage/backend/quota/global-quota-file.json | 2 +- config/storage/backend/quota/pod-quota-file.json | 2 +- config/storage/backend/quota/quota-file.json | 2 +- config/storage/backend/regex.json | 2 +- config/storage/backend/sparql.json | 2 +- config/storage/key-value/memory.json | 2 +- config/storage/key-value/resource-store.json | 2 +- config/storage/middleware/default.json | 2 +- config/storage/middleware/stores/converting.json | 2 +- config/storage/middleware/stores/locking.json | 2 +- config/storage/middleware/stores/monitoring.json | 2 +- config/storage/middleware/stores/patching.json | 2 +- config/util/auxiliary/acl.json | 2 +- config/util/auxiliary/no-acl.json | 2 +- config/util/auxiliary/strategies/acl.json | 2 +- config/util/identifiers/subdomain.json | 2 +- config/util/identifiers/suffix.json | 2 +- config/util/index/default.json | 2 +- config/util/index/example.json | 2 +- config/util/logging/no-logging.json | 2 +- config/util/logging/winston.json | 2 +- .../converters/content-type-replacer.json | 2 +- .../converters/dynamic-json-template.json | 2 +- .../representation-conversion/converters/errors.json | 2 +- .../converters/form-to-json.json | 2 +- .../representation-conversion/converters/markdown.json | 2 +- .../converters/quad-to-rdf.json | 2 +- .../converters/rdf-to-quad.json | 2 +- config/util/representation-conversion/default.json | 2 +- config/util/resource-locker/debug-void.json | 2 +- config/util/resource-locker/memory.json | 2 +- config/util/resource-locker/redis.json | 2 +- config/util/variables/default.json | 2 +- package.json | 10 +++++----- templates/config/defaults.json | 2 +- templates/config/filesystem.json | 2 +- templates/config/memory.json | 2 +- test/integration/config/ldp-with-auth.json | 2 +- test/integration/config/quota-global.json | 2 +- test/integration/config/quota-pod.json | 2 +- test/integration/config/restricted-idp.json | 2 +- test/integration/config/run-with-redlock.json | 2 +- test/integration/config/server-dynamic-unsafe.json | 2 +- test/integration/config/server-memory.json | 2 +- test/integration/config/server-middleware.json | 2 +- test/integration/config/server-subdomains-unsafe.json | 2 +- test/integration/config/server-without-auth.json | 2 +- test/integration/config/setup-memory.json | 2 +- 157 files changed, 161 insertions(+), 161 deletions(-) diff --git a/config/app/init/base/init.json b/config/app/init/base/init.json index f3fad40d1..0036fdbe8 100644 --- a/config/app/init/base/init.json +++ b/config/app/init/base/init.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/init/initializers/base-url.json", "files-scs:config/app/init/initializers/logger.json", diff --git a/config/app/init/default.json b/config/app/init/default.json index 88037a1e2..2b117336b 100644 --- a/config/app/init/default.json +++ b/config/app/init/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/init/base/init.json" ], diff --git a/config/app/init/initialize-prefilled-root.json b/config/app/init/initialize-prefilled-root.json index 40e1d5d85..7eae44141 100644 --- a/config/app/init/initialize-prefilled-root.json +++ b/config/app/init/initialize-prefilled-root.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/init/base/init.json", "files-scs:config/app/init/initializers/prefilled-root.json" diff --git a/config/app/init/initialize-root.json b/config/app/init/initialize-root.json index fea2858a1..c72c66c3c 100644 --- a/config/app/init/initialize-root.json +++ b/config/app/init/initialize-root.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/init/base/init.json", "files-scs:config/app/init/initializers/root.json" diff --git a/config/app/init/initializers/base-url.json b/config/app/init/initializers/base-url.json index 0eb1176dd..d9c113551 100644 --- a/config/app/init/initializers/base-url.json +++ b/config/app/init/initializers/base-url.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Logs a warning if the base URL changes.", diff --git a/config/app/init/initializers/logger.json b/config/app/init/initializers/logger.json index c1829d165..d1cf5fab8 100644 --- a/config/app/init/initializers/logger.json +++ b/config/app/init/initializers/logger.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": [ diff --git a/config/app/init/initializers/prefilled-root.json b/config/app/init/initializers/prefilled-root.json index 0fea154a7..2f934a3c4 100644 --- a/config/app/init/initializers/prefilled-root.json +++ b/config/app/init/initializers/prefilled-root.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Makes sure the root container exists and contains the necessary resources.", diff --git a/config/app/init/initializers/root.json b/config/app/init/initializers/root.json index 314bd8125..0e0ceb912 100644 --- a/config/app/init/initializers/root.json +++ b/config/app/init/initializers/root.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Makes sure the root container exists and contains the necessary resources.", diff --git a/config/app/init/initializers/server.json b/config/app/init/initializers/server.json index ccaeffdc3..f227a7fb7 100644 --- a/config/app/init/initializers/server.json +++ b/config/app/init/initializers/server.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Creates the server that starts listening for requests.", diff --git a/config/app/init/initializers/version.json b/config/app/init/initializers/version.json index ad06d3d1f..42b1e4bfb 100644 --- a/config/app/init/initializers/version.json +++ b/config/app/init/initializers/version.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Logs a warning if the base URL changes.", diff --git a/config/app/main/default.json b/config/app/main/default.json index d5657700f..d723c4b1a 100644 --- a/config/app/main/default.json +++ b/config/app/main/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "This is the entry point to the application. It can be used to both start and stop the server.", diff --git a/config/app/setup/disabled.json b/config/app/setup/disabled.json index 39901fa04..4e02f171d 100644 --- a/config/app/setup/disabled.json +++ b/config/app/setup/disabled.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/init/initializers/root.json" ], diff --git a/config/app/setup/handlers/redirect.json b/config/app/setup/handlers/redirect.json index 548ae736a..12c0c584b 100644 --- a/config/app/setup/handlers/redirect.json +++ b/config/app/setup/handlers/redirect.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Redirects all request to the setup.", diff --git a/config/app/setup/handlers/setup.json b/config/app/setup/handlers/setup.json index ea279ffd3..4dcb0ffe6 100644 --- a/config/app/setup/handlers/setup.json +++ b/config/app/setup/handlers/setup.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/init/initializers/root.json" ], diff --git a/config/app/setup/optional.json b/config/app/setup/optional.json index 921596deb..33cd597e5 100644 --- a/config/app/setup/optional.json +++ b/config/app/setup/optional.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/setup/handlers/setup.json" ], diff --git a/config/app/setup/required.json b/config/app/setup/required.json index 2bfc0b6aa..8056955c2 100644 --- a/config/app/setup/required.json +++ b/config/app/setup/required.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/setup/handlers/redirect.json", "files-scs:config/app/setup/handlers/setup.json" diff --git a/config/app/variables/cli/cli.json b/config/app/variables/cli/cli.json index cc44651c2..63ac5fa4f 100644 --- a/config/app/variables/cli/cli.json +++ b/config/app/variables/cli/cli.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Extracts CLI arguments into a key/value object. Config and mainModulePath are only defined here so their description is returned.", diff --git a/config/app/variables/default.json b/config/app/variables/default.json index 103467e2a..bf72b1083 100644 --- a/config/app/variables/default.json +++ b/config/app/variables/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/variables/cli/cli.json", "files-scs:config/app/variables/resolver/resolver.json" diff --git a/config/app/variables/resolver/resolver.json b/config/app/variables/resolver/resolver.json index 46cf92016..9f5e2e749 100644 --- a/config/app/variables/resolver/resolver.json +++ b/config/app/variables/resolver/resolver.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts an input key/value object into an object mapping values to Components.js variables", diff --git a/config/default.json b/config/default.json index 709856448..0a05b7009 100644 --- a/config/default.json +++ b/config/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-prefilled-root.json", diff --git a/config/dynamic.json b/config/dynamic.json index d731158e9..d12b88f28 100644 --- a/config/dynamic.json +++ b/config/dynamic.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", diff --git a/config/example-https-file.json b/config/example-https-file.json index 8ec5248bc..f3a8f17e4 100644 --- a/config/example-https-file.json +++ b/config/example-https-file.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", diff --git a/config/file-no-setup.json b/config/file-no-setup.json index b4c5096a2..11d93a85a 100644 --- a/config/file-no-setup.json +++ b/config/file-no-setup.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", diff --git a/config/file.json b/config/file.json index 40948c7ed..873ca50b0 100644 --- a/config/file.json +++ b/config/file.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", diff --git a/config/http/handler/default.json b/config/http/handler/default.json index acbcfb97d..1a6ec5566 100644 --- a/config/http/handler/default.json +++ b/config/http/handler/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/http/handler/handlers/oidc.json" ], diff --git a/config/http/handler/handlers/oidc.json b/config/http/handler/handlers/oidc.json index e9cf0fa0f..e31e2d109 100644 --- a/config/http/handler/handlers/oidc.json +++ b/config/http/handler/handlers/oidc.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Routes all OIDC related requests to the OIDC library.", diff --git a/config/http/handler/simple.json b/config/http/handler/simple.json index 5d1b02d17..f71a54778 100644 --- a/config/http/handler/simple.json +++ b/config/http/handler/simple.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "This version of the server has no IDP or pod provisioning.", diff --git a/config/http/middleware/handlers/constant-headers.json b/config/http/middleware/handlers/constant-headers.json index dc36a8ab6..eecae8cae 100644 --- a/config/http/middleware/handlers/constant-headers.json +++ b/config/http/middleware/handlers/constant-headers.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Adds several constant headers.", diff --git a/config/http/middleware/handlers/cors.json b/config/http/middleware/handlers/cors.json index 8f7b66e08..f818b6c36 100644 --- a/config/http/middleware/handlers/cors.json +++ b/config/http/middleware/handlers/cors.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Adds all the necessary CORS headers.", diff --git a/config/http/middleware/handlers/updates-via.json b/config/http/middleware/handlers/updates-via.json index a7f4af6e6..cf584d166 100644 --- a/config/http/middleware/handlers/updates-via.json +++ b/config/http/middleware/handlers/updates-via.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Advertises the websocket connection.", diff --git a/config/http/middleware/no-websockets.json b/config/http/middleware/no-websockets.json index 918e868e3..dffbc5bdc 100644 --- a/config/http/middleware/no-websockets.json +++ b/config/http/middleware/no-websockets.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/http/middleware/handlers/constant-headers.json", "files-scs:config/http/middleware/handlers/cors.json" diff --git a/config/http/middleware/websockets.json b/config/http/middleware/websockets.json index 960b1d6e5..8ef50ddb7 100644 --- a/config/http/middleware/websockets.json +++ b/config/http/middleware/websockets.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/http/middleware/handlers/constant-headers.json", "files-scs:config/http/middleware/handlers/cors.json", diff --git a/config/http/server-factory/https-example.json b/config/http/server-factory/https-example.json index 612b27879..b3ef1d75a 100644 --- a/config/http/server-factory/https-example.json +++ b/config/http/server-factory/https-example.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "An example of how to set up a server with HTTPS", diff --git a/config/http/server-factory/no-websockets.json b/config/http/server-factory/no-websockets.json index 6ef20bd2f..1b0ef0203 100644 --- a/config/http/server-factory/no-websockets.json +++ b/config/http/server-factory/no-websockets.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Creates a server that supports HTTP requests.", diff --git a/config/http/server-factory/websockets.json b/config/http/server-factory/websockets.json index 2dfc9d031..65b446492 100644 --- a/config/http/server-factory/websockets.json +++ b/config/http/server-factory/websockets.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Creates a server that supports both websocket and HTTP requests.", diff --git a/config/http/static/default.json b/config/http/static/default.json index 9dfe69f57..e78beb842 100644 --- a/config/http/static/default.json +++ b/config/http/static/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Servers static files on fixed URLs.", diff --git a/config/identity/access/initializers/idp.json b/config/identity/access/initializers/idp.json index dad2509f5..a68036fc7 100644 --- a/config/identity/access/initializers/idp.json +++ b/config/identity/access/initializers/idp.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Makes sure the IDP container has the necessary root resources.", diff --git a/config/identity/access/initializers/well-known.json b/config/identity/access/initializers/well-known.json index b700434de..1807343e3 100644 --- a/config/identity/access/initializers/well-known.json +++ b/config/identity/access/initializers/well-known.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Makes sure the .well-known container has the necessary root resources. Some IDP resources are stored there due to OIDC requirements.", diff --git a/config/identity/access/public.json b/config/identity/access/public.json index 572208501..1bcce633d 100644 --- a/config/identity/access/public.json +++ b/config/identity/access/public.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Allow everyone to register new pods.", diff --git a/config/identity/access/restricted.json b/config/identity/access/restricted.json index d3799afa1..03457e71c 100644 --- a/config/identity/access/restricted.json +++ b/config/identity/access/restricted.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/identity/access/initializers/idp.json", "files-scs:config/identity/access/initializers/well-known.json" diff --git a/config/identity/email/default.json b/config/identity/email/default.json index fefb97680..1f252d9c3 100644 --- a/config/identity/email/default.json +++ b/config/identity/email/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "The default configuration does not contain credentials for an email client. In production systems, you likely want to set up your own.", diff --git a/config/identity/email/example.json b/config/identity/email/example.json index 82d6d99a7..5b0173fa0 100644 --- a/config/identity/email/example.json +++ b/config/identity/email/example.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "This is an example of what an actual email sender configuration would look like.", diff --git a/config/identity/handler/account-store/default.json b/config/identity/handler/account-store/default.json index 71cad4ca3..aae44a0ee 100644 --- a/config/identity/handler/account-store/default.json +++ b/config/identity/handler/account-store/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "The storage adapter that persists usernames, passwords, etc.", diff --git a/config/identity/handler/adapter-factory/webid.json b/config/identity/handler/adapter-factory/webid.json index 1a6bf0ba0..502dc334f 100644 --- a/config/identity/handler/adapter-factory/webid.json +++ b/config/identity/handler/adapter-factory/webid.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "An adapter is responsible for storing all interaction metadata.", diff --git a/config/identity/handler/default.json b/config/identity/handler/default.json index fd57f21cd..f4b15ca9a 100644 --- a/config/identity/handler/default.json +++ b/config/identity/handler/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/identity/handler/account-store/default.json", "files-scs:config/identity/handler/adapter-factory/webid.json", diff --git a/config/identity/handler/interaction/routes.json b/config/identity/handler/interaction/routes.json index dd6ccf59b..e49083ba2 100644 --- a/config/identity/handler/interaction/routes.json +++ b/config/identity/handler/interaction/routes.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/identity/handler/interaction/routes/consent.json", "files-scs:config/identity/handler/interaction/routes/forgot-password.json", diff --git a/config/identity/handler/interaction/routes/consent.json b/config/identity/handler/interaction/routes/consent.json index e2fcfb60a..fd7419a06 100644 --- a/config/identity/handler/interaction/routes/consent.json +++ b/config/identity/handler/interaction/routes/consent.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Handles the interaction that occurs when a logged in user wants to authenticate with a new app.", diff --git a/config/identity/handler/interaction/routes/forgot-password.json b/config/identity/handler/interaction/routes/forgot-password.json index 748d58a0a..7bf2b117e 100644 --- a/config/identity/handler/interaction/routes/forgot-password.json +++ b/config/identity/handler/interaction/routes/forgot-password.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Handles the forgot password interaction", diff --git a/config/identity/handler/interaction/routes/index.json b/config/identity/handler/interaction/routes/index.json index 145b4fcf2..2c935720f 100644 --- a/config/identity/handler/interaction/routes/index.json +++ b/config/identity/handler/interaction/routes/index.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Root API entry. Returns an empty body so we can add controls pointing to other interaction routes.", diff --git a/config/identity/handler/interaction/routes/login.json b/config/identity/handler/interaction/routes/login.json index ecab17bda..626b398d7 100644 --- a/config/identity/handler/interaction/routes/login.json +++ b/config/identity/handler/interaction/routes/login.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Handles the login interaction", diff --git a/config/identity/handler/interaction/routes/prompt.json b/config/identity/handler/interaction/routes/prompt.json index cea3ce87e..538ae07c0 100644 --- a/config/identity/handler/interaction/routes/prompt.json +++ b/config/identity/handler/interaction/routes/prompt.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Handles OIDC redirects containing a prompt, such as login or consent.", diff --git a/config/identity/handler/interaction/routes/reset-password.json b/config/identity/handler/interaction/routes/reset-password.json index ef6aa9335..7c33c9e10 100644 --- a/config/identity/handler/interaction/routes/reset-password.json +++ b/config/identity/handler/interaction/routes/reset-password.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Handles the reset password interaction", diff --git a/config/identity/handler/interaction/views/controls.json b/config/identity/handler/interaction/views/controls.json index cef0c83cb..ab514e16a 100644 --- a/config/identity/handler/interaction/views/controls.json +++ b/config/identity/handler/interaction/views/controls.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "@id": "urn:solid-server:auth:password:ControlHandler", diff --git a/config/identity/handler/interaction/views/html.json b/config/identity/handler/interaction/views/html.json index d70f0170c..f99bc125a 100644 --- a/config/identity/handler/interaction/views/html.json +++ b/config/identity/handler/interaction/views/html.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "@id": "urn:solid-server:auth:password:HtmlViewHandler", diff --git a/config/identity/handler/provider-factory/identity.json b/config/identity/handler/provider-factory/identity.json index ed9c505f2..04bed93e1 100644 --- a/config/identity/handler/provider-factory/identity.json +++ b/config/identity/handler/provider-factory/identity.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": [ diff --git a/config/identity/ownership/token.json b/config/identity/ownership/token.json index e5d9fdace..363186c8a 100644 --- a/config/identity/ownership/token.json +++ b/config/identity/ownership/token.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Determines WebID ownership by requesting a specific value to be added to the WebID document", diff --git a/config/identity/ownership/unsafe-no-check.json b/config/identity/ownership/unsafe-no-check.json index 400e5e090..ab43da0a9 100644 --- a/config/identity/ownership/unsafe-no-check.json +++ b/config/identity/ownership/unsafe-no-check.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": [ diff --git a/config/identity/pod/dynamic.json b/config/identity/pod/dynamic.json index 2c6dc2f49..052b84bb1 100644 --- a/config/identity/pod/dynamic.json +++ b/config/identity/pod/dynamic.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/identity/pod/pod-generators/templated.json", "files-scs:config/identity/pod/resource-generators/templated.json" diff --git a/config/identity/pod/pod-generators/templated.json b/config/identity/pod/pod-generators/templated.json index 81e1a2bf0..da78a796a 100644 --- a/config/identity/pod/pod-generators/templated.json +++ b/config/identity/pod/pod-generators/templated.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Generates ResourceStores that correspond to new pods.", diff --git a/config/identity/pod/resource-generators/templated.json b/config/identity/pod/resource-generators/templated.json index 98963f350..fc2b6b1ab 100644 --- a/config/identity/pod/resource-generators/templated.json +++ b/config/identity/pod/resource-generators/templated.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Generates resources based on the templates stored in the template folder.", diff --git a/config/identity/pod/static.json b/config/identity/pod/static.json index f7add1c83..47bc308e8 100644 --- a/config/identity/pod/static.json +++ b/config/identity/pod/static.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/identity/pod/resource-generators/templated.json" ], diff --git a/config/identity/registration/disabled.json b/config/identity/registration/disabled.json index 712f75207..97c7b8192 100644 --- a/config/identity/registration/disabled.json +++ b/config/identity/registration/disabled.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Disable registration by not attaching a registration handler." diff --git a/config/identity/registration/enabled.json b/config/identity/registration/enabled.json index 5caedc4f4..a781dc9a5 100644 --- a/config/identity/registration/enabled.json +++ b/config/identity/registration/enabled.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/identity/registration/route/registration.json" ], diff --git a/config/identity/registration/route/registration.json b/config/identity/registration/route/registration.json index 693c9ba3e..16e9ded4c 100644 --- a/config/identity/registration/route/registration.json +++ b/config/identity/registration/route/registration.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Handles the register interaction", diff --git a/config/ldp/authentication/debug-auth-header.json b/config/ldp/authentication/debug-auth-header.json index 92ebefc34..fc194c14a 100644 --- a/config/ldp/authentication/debug-auth-header.json +++ b/config/ldp/authentication/debug-auth-header.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": [ diff --git a/config/ldp/authentication/debug-test-agent.json b/config/ldp/authentication/debug-test-agent.json index 96fb75313..2b230b1be 100644 --- a/config/ldp/authentication/debug-test-agent.json +++ b/config/ldp/authentication/debug-test-agent.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": [ diff --git a/config/ldp/authentication/dpop-bearer.json b/config/ldp/authentication/dpop-bearer.json index e30632089..385841ebd 100644 --- a/config/ldp/authentication/dpop-bearer.json +++ b/config/ldp/authentication/dpop-bearer.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Supports DPoP and Bearer access tokens, or no credentials.", diff --git a/config/ldp/authorization/allow-all.json b/config/ldp/authorization/allow-all.json index 149e5808a..183b28ea0 100644 --- a/config/ldp/authorization/allow-all.json +++ b/config/ldp/authorization/allow-all.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": [ diff --git a/config/ldp/authorization/readers/access-checkers/agent-class.json b/config/ldp/authorization/readers/access-checkers/agent-class.json index a86b314ea..504274359 100644 --- a/config/ldp/authorization/readers/access-checkers/agent-class.json +++ b/config/ldp/authorization/readers/access-checkers/agent-class.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Checks access based on the agent being authenticated or not.", diff --git a/config/ldp/authorization/readers/access-checkers/agent-group.json b/config/ldp/authorization/readers/access-checkers/agent-group.json index 9cf614b1c..fafe17aa4 100644 --- a/config/ldp/authorization/readers/access-checkers/agent-group.json +++ b/config/ldp/authorization/readers/access-checkers/agent-group.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Checks if the agent belongs to a group that has access.", diff --git a/config/ldp/authorization/readers/access-checkers/agent.json b/config/ldp/authorization/readers/access-checkers/agent.json index ab62e7bfd..f87e1fd7b 100644 --- a/config/ldp/authorization/readers/access-checkers/agent.json +++ b/config/ldp/authorization/readers/access-checkers/agent.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Checks if a specific WebID has access.", diff --git a/config/ldp/authorization/readers/acl.json b/config/ldp/authorization/readers/acl.json index 3f2fe1ab8..cf1f2177c 100644 --- a/config/ldp/authorization/readers/acl.json +++ b/config/ldp/authorization/readers/acl.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/ldp/authorization/readers/access-checkers/agent.json", "files-scs:config/ldp/authorization/readers/access-checkers/agent-class.json", diff --git a/config/ldp/authorization/readers/ownership.json b/config/ldp/authorization/readers/ownership.json index 69bee3242..79863d37c 100644 --- a/config/ldp/authorization/readers/ownership.json +++ b/config/ldp/authorization/readers/ownership.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Allows pod owners to always edit permissions on the data.", diff --git a/config/ldp/authorization/webacl.json b/config/ldp/authorization/webacl.json index ee8a74b7a..61c208dac 100644 --- a/config/ldp/authorization/webacl.json +++ b/config/ldp/authorization/webacl.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/ldp/authorization/readers/acl.json", "files-scs:config/ldp/authorization/readers/ownership.json" diff --git a/config/ldp/handler/components/authorizer.json b/config/ldp/handler/components/authorizer.json index 08dd27836..a962ef954 100644 --- a/config/ldp/handler/components/authorizer.json +++ b/config/ldp/handler/components/authorizer.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Matches requested permissions with those available.", diff --git a/config/ldp/handler/components/error-handler.json b/config/ldp/handler/components/error-handler.json index c55579303..abfbfacfe 100644 --- a/config/ldp/handler/components/error-handler.json +++ b/config/ldp/handler/components/error-handler.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Wraps around the main error handler as a fallback in case something goes wrong.", diff --git a/config/ldp/handler/components/operation-handler.json b/config/ldp/handler/components/operation-handler.json index 582354ca9..0a15f9ecc 100644 --- a/config/ldp/handler/components/operation-handler.json +++ b/config/ldp/handler/components/operation-handler.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "@id": "urn:solid-server:default:OperationHandler", diff --git a/config/ldp/handler/components/operation-metadata.json b/config/ldp/handler/components/operation-metadata.json index 283944802..951958aec 100644 --- a/config/ldp/handler/components/operation-metadata.json +++ b/config/ldp/handler/components/operation-metadata.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "@id": "urn:solid-server:default:OperationMetadataCollector", diff --git a/config/ldp/handler/components/request-parser.json b/config/ldp/handler/components/request-parser.json index af950558e..e49ceb190 100644 --- a/config/ldp/handler/components/request-parser.json +++ b/config/ldp/handler/components/request-parser.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Handles everything related to parsing a Request.", diff --git a/config/ldp/handler/components/response-writer.json b/config/ldp/handler/components/response-writer.json index 4adee6290..a9c8a3ed3 100644 --- a/config/ldp/handler/components/response-writer.json +++ b/config/ldp/handler/components/response-writer.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Writes the result to the response stream.", diff --git a/config/ldp/handler/default.json b/config/ldp/handler/default.json index 4aa398581..b6046e468 100644 --- a/config/ldp/handler/default.json +++ b/config/ldp/handler/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/ldp/handler/components/authorizer.json", "files-scs:config/ldp/handler/components/error-handler.json", diff --git a/config/ldp/metadata-parser/default.json b/config/ldp/metadata-parser/default.json index e782bfd4b..e886b3b90 100644 --- a/config/ldp/metadata-parser/default.json +++ b/config/ldp/metadata-parser/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/ldp/metadata-parser/parsers/content-type.json", "files-scs:config/ldp/metadata-parser/parsers/content-length.json", diff --git a/config/ldp/metadata-parser/parsers/content-length.json b/config/ldp/metadata-parser/parsers/content-length.json index 1ec1a2311..d66a6452a 100644 --- a/config/ldp/metadata-parser/parsers/content-length.json +++ b/config/ldp/metadata-parser/parsers/content-length.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts content-length headers into RDF metadata.", diff --git a/config/ldp/metadata-parser/parsers/content-type.json b/config/ldp/metadata-parser/parsers/content-type.json index 5589a9718..3b77acd18 100644 --- a/config/ldp/metadata-parser/parsers/content-type.json +++ b/config/ldp/metadata-parser/parsers/content-type.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts content-type headers into RDF metadata.", diff --git a/config/ldp/metadata-parser/parsers/link.json b/config/ldp/metadata-parser/parsers/link.json index 5ef87460b..15ed1922e 100644 --- a/config/ldp/metadata-parser/parsers/link.json +++ b/config/ldp/metadata-parser/parsers/link.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts link headers into RDF metadata.", diff --git a/config/ldp/metadata-parser/parsers/slug.json b/config/ldp/metadata-parser/parsers/slug.json index 35524b515..f54db9ed5 100644 --- a/config/ldp/metadata-parser/parsers/slug.json +++ b/config/ldp/metadata-parser/parsers/slug.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts slug headers into RDF metadata.", diff --git a/config/ldp/metadata-writer/default.json b/config/ldp/metadata-writer/default.json index 70f6234d3..86230d135 100644 --- a/config/ldp/metadata-writer/default.json +++ b/config/ldp/metadata-writer/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/ldp/metadata-writer/writers/constant.json", "files-scs:config/ldp/metadata-writer/writers/link-rel.json", diff --git a/config/ldp/metadata-writer/writers/constant.json b/config/ldp/metadata-writer/writers/constant.json index ea4455d4b..542450d90 100644 --- a/config/ldp/metadata-writer/writers/constant.json +++ b/config/ldp/metadata-writer/writers/constant.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Adds fixed headers to the response.", diff --git a/config/ldp/metadata-writer/writers/link-rel.json b/config/ldp/metadata-writer/writers/link-rel.json index 47d07f060..2fb4383cd 100644 --- a/config/ldp/metadata-writer/writers/link-rel.json +++ b/config/ldp/metadata-writer/writers/link-rel.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts all triples with the given predicates to Link headers.", diff --git a/config/ldp/metadata-writer/writers/mapped.json b/config/ldp/metadata-writer/writers/mapped.json index c3013cadb..45748bc55 100644 --- a/config/ldp/metadata-writer/writers/mapped.json +++ b/config/ldp/metadata-writer/writers/mapped.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts all triples with the given predicate to headers of the given type.", diff --git a/config/ldp/metadata-writer/writers/modified.json b/config/ldp/metadata-writer/writers/modified.json index 901247041..f000a0f12 100644 --- a/config/ldp/metadata-writer/writers/modified.json +++ b/config/ldp/metadata-writer/writers/modified.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Adds the Last-Modified and ETag headers.", diff --git a/config/ldp/metadata-writer/writers/wac-allow.json b/config/ldp/metadata-writer/writers/wac-allow.json index 9a0efde98..c9a20e2fa 100644 --- a/config/ldp/metadata-writer/writers/wac-allow.json +++ b/config/ldp/metadata-writer/writers/wac-allow.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Adds the correct Wac-Allow header.", diff --git a/config/ldp/metadata-writer/writers/www-auth.json b/config/ldp/metadata-writer/writers/www-auth.json index 859a78646..7c19e328b 100644 --- a/config/ldp/metadata-writer/writers/www-auth.json +++ b/config/ldp/metadata-writer/writers/www-auth.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Adds the WWW-Authenticate header on 401 responses. The current auth value is required for the legacy solid-auth-client.", diff --git a/config/ldp/modes/default.json b/config/ldp/modes/default.json index 403f25bed..d7de98099 100644 --- a/config/ldp/modes/default.json +++ b/config/ldp/modes/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Determines required modes based on HTTP methods.", diff --git a/config/memory-subdomains.json b/config/memory-subdomains.json index bb1d61362..b2c0925b1 100644 --- a/config/memory-subdomains.json +++ b/config/memory-subdomains.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", diff --git a/config/path-routing.json b/config/path-routing.json index 50296f710..ee9c6eb3c 100644 --- a/config/path-routing.json +++ b/config/path-routing.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", diff --git a/config/quota-file.json b/config/quota-file.json index d551f8118..070751620 100644 --- a/config/quota-file.json +++ b/config/quota-file.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", diff --git a/config/restrict-idp.json b/config/restrict-idp.json index f531031d9..5570f8f87 100644 --- a/config/restrict-idp.json +++ b/config/restrict-idp.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", diff --git a/config/sparql-endpoint-no-setup.json b/config/sparql-endpoint-no-setup.json index 04ea8ebbe..6f1451a1e 100644 --- a/config/sparql-endpoint-no-setup.json +++ b/config/sparql-endpoint-no-setup.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", diff --git a/config/sparql-endpoint.json b/config/sparql-endpoint.json index bf63d0453..eee264d88 100644 --- a/config/sparql-endpoint.json +++ b/config/sparql-endpoint.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", diff --git a/config/sparql-file-storage.json b/config/sparql-file-storage.json index ecf08373c..1a8401669 100644 --- a/config/sparql-file-storage.json +++ b/config/sparql-file-storage.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", diff --git a/config/storage/backend/data-accessors/file.json b/config/storage/backend/data-accessors/file.json index a8414c938..4548fc9db 100644 --- a/config/storage/backend/data-accessors/file.json +++ b/config/storage/backend/data-accessors/file.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Stores data on a file system.", diff --git a/config/storage/backend/data-accessors/memory.json b/config/storage/backend/data-accessors/memory.json index 2a398064b..f6d733e25 100644 --- a/config/storage/backend/data-accessors/memory.json +++ b/config/storage/backend/data-accessors/memory.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Stores data in memory.", diff --git a/config/storage/backend/data-accessors/sparql-endpoint.json b/config/storage/backend/data-accessors/sparql-endpoint.json index dc4fc1854..6d6318e83 100644 --- a/config/storage/backend/data-accessors/sparql-endpoint.json +++ b/config/storage/backend/data-accessors/sparql-endpoint.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Stores data on a SPARQL endpoint. Only supports quad object streams.", diff --git a/config/storage/backend/dynamic.json b/config/storage/backend/dynamic.json index 84675d8d4..e046af8ac 100644 --- a/config/storage/backend/dynamic.json +++ b/config/storage/backend/dynamic.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/storage/backend/data-accessors/file.json" ], diff --git a/config/storage/backend/file.json b/config/storage/backend/file.json index bef105a76..45bc7b015 100644 --- a/config/storage/backend/file.json +++ b/config/storage/backend/file.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/storage/backend/data-accessors/file.json" ], diff --git a/config/storage/backend/global-quota-file.json b/config/storage/backend/global-quota-file.json index ea40bfc6d..9d7d2f0b0 100644 --- a/config/storage/backend/global-quota-file.json +++ b/config/storage/backend/global-quota-file.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/storage/backend/quota/global-quota-file.json", "files-scs:config/storage/backend/quota/quota-file.json" diff --git a/config/storage/backend/memory.json b/config/storage/backend/memory.json index 46a1da431..ba8ab3fc0 100644 --- a/config/storage/backend/memory.json +++ b/config/storage/backend/memory.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/storage/backend/data-accessors/memory.json" ], diff --git a/config/storage/backend/pod-quota-file.json b/config/storage/backend/pod-quota-file.json index 00da72df5..81fdeadaa 100644 --- a/config/storage/backend/pod-quota-file.json +++ b/config/storage/backend/pod-quota-file.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/storage/backend/quota/pod-quota-file.json", "files-scs:config/storage/backend/quota/quota-file.json" diff --git a/config/storage/backend/quota/global-quota-file.json b/config/storage/backend/quota/global-quota-file.json index d61cd8151..2747c7aa2 100644 --- a/config/storage/backend/quota/global-quota-file.json +++ b/config/storage/backend/quota/global-quota-file.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "comment": "Configuration of a GlobalQuotaStrategy to enforce quota globally on the server.", "@graph": [ { diff --git a/config/storage/backend/quota/pod-quota-file.json b/config/storage/backend/quota/pod-quota-file.json index f36529c60..f69b4f267 100644 --- a/config/storage/backend/quota/pod-quota-file.json +++ b/config/storage/backend/quota/pod-quota-file.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "comment": "Configuration of a PodQuotaStrategy to enforce pod quotas on the server.", "@graph": [ { diff --git a/config/storage/backend/quota/quota-file.json b/config/storage/backend/quota/quota-file.json index 03516a5e0..d168d1941 100644 --- a/config/storage/backend/quota/quota-file.json +++ b/config/storage/backend/quota/quota-file.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "comment": "DataAccessor configuration using a QuotaStrategy to enforce quota on the server.", "@graph": [ { diff --git a/config/storage/backend/regex.json b/config/storage/backend/regex.json index 05e0ea78a..ee1a3a328 100644 --- a/config/storage/backend/regex.json +++ b/config/storage/backend/regex.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/storage/backend/data-accessors/file.json", "files-scs:config/storage/backend/data-accessors/memory.json", diff --git a/config/storage/backend/sparql.json b/config/storage/backend/sparql.json index 8954fe94c..02224fddb 100644 --- a/config/storage/backend/sparql.json +++ b/config/storage/backend/sparql.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/storage/backend/data-accessors/sparql-endpoint.json" ], diff --git a/config/storage/key-value/memory.json b/config/storage/key-value/memory.json index 19228f9af..92409512f 100644 --- a/config/storage/key-value/memory.json +++ b/config/storage/key-value/memory.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "These storage solutions store their data in memory." diff --git a/config/storage/key-value/resource-store.json b/config/storage/key-value/resource-store.json index 74f90d67f..58f5c711c 100644 --- a/config/storage/key-value/resource-store.json +++ b/config/storage/key-value/resource-store.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "These storage solutions use the specified container in the ResourceStore to store their data." diff --git a/config/storage/middleware/default.json b/config/storage/middleware/default.json index 89f765fb2..6b4f041c5 100644 --- a/config/storage/middleware/default.json +++ b/config/storage/middleware/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/storage/middleware/stores/converting.json", "files-scs:config/storage/middleware/stores/locking.json", diff --git a/config/storage/middleware/stores/converting.json b/config/storage/middleware/stores/converting.json index 5b5378c47..0c2c86078 100644 --- a/config/storage/middleware/stores/converting.json +++ b/config/storage/middleware/stores/converting.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts all outgoing resources based on the preferences.", diff --git a/config/storage/middleware/stores/locking.json b/config/storage/middleware/stores/locking.json index c41af95c3..b67ac78df 100644 --- a/config/storage/middleware/stores/locking.json +++ b/config/storage/middleware/stores/locking.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Locks resources when they are accessed until the operation is finished.", diff --git a/config/storage/middleware/stores/monitoring.json b/config/storage/middleware/stores/monitoring.json index 980058fff..8df191730 100644 --- a/config/storage/middleware/stores/monitoring.json +++ b/config/storage/middleware/stores/monitoring.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Emits events on changes to resources.", diff --git a/config/storage/middleware/stores/patching.json b/config/storage/middleware/stores/patching.json index 480253f91..cc1e71532 100644 --- a/config/storage/middleware/stores/patching.json +++ b/config/storage/middleware/stores/patching.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Allows for PATCH operations on stores that don't have native support.", diff --git a/config/util/auxiliary/acl.json b/config/util/auxiliary/acl.json index fb413929c..d3bea924f 100644 --- a/config/util/auxiliary/acl.json +++ b/config/util/auxiliary/acl.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/util/auxiliary/strategies/acl.json" ], diff --git a/config/util/auxiliary/no-acl.json b/config/util/auxiliary/no-acl.json index 19c6a3fcf..01e1614cb 100644 --- a/config/util/auxiliary/no-acl.json +++ b/config/util/auxiliary/no-acl.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": [ diff --git a/config/util/auxiliary/strategies/acl.json b/config/util/auxiliary/strategies/acl.json index 1d0674095..042434dfb 100644 --- a/config/util/auxiliary/strategies/acl.json +++ b/config/util/auxiliary/strategies/acl.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Contains all features of acl auxiliary resources (suffix, link header, etc.).", diff --git a/config/util/identifiers/subdomain.json b/config/util/identifiers/subdomain.json index 90e1d39ea..158eb61b5 100644 --- a/config/util/identifiers/subdomain.json +++ b/config/util/identifiers/subdomain.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "comment": "Supports multiple roots such as both http://test.com/ and http://alice.test.com/.", "@graph": [ { diff --git a/config/util/identifiers/suffix.json b/config/util/identifiers/suffix.json index 88df3ef21..352962b33 100644 --- a/config/util/identifiers/suffix.json +++ b/config/util/identifiers/suffix.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "comment": "Only supports a single root, such as http://test.com/. A new pod URL would be http://test.com/alice/.", "@graph": [ { diff --git a/config/util/index/default.json b/config/util/index/default.json index 68cedd743..3d417d340 100644 --- a/config/util/index/default.json +++ b/config/util/index/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "This value can be used to set a custom handler for index files. See the example file.", diff --git a/config/util/index/example.json b/config/util/index/example.json index ebae46e59..211e4dfb4 100644 --- a/config/util/index/example.json +++ b/config/util/index/example.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": [ diff --git a/config/util/logging/no-logging.json b/config/util/logging/no-logging.json index 6a370ef1e..52046e6f4 100644 --- a/config/util/logging/no-logging.json +++ b/config/util/logging/no-logging.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Ignores log messages.", diff --git a/config/util/logging/winston.json b/config/util/logging/winston.json index 07fc822f2..81cb4fea0 100644 --- a/config/util/logging/winston.json +++ b/config/util/logging/winston.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Uses the winston library for logging", diff --git a/config/util/representation-conversion/converters/content-type-replacer.json b/config/util/representation-conversion/converters/content-type-replacer.json index b7c88ec58..233995e6d 100644 --- a/config/util/representation-conversion/converters/content-type-replacer.json +++ b/config/util/representation-conversion/converters/content-type-replacer.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Changes content-type without changing content. E.g., if application/json is requested, application/ld+json is also valid.", diff --git a/config/util/representation-conversion/converters/dynamic-json-template.json b/config/util/representation-conversion/converters/dynamic-json-template.json index c381c0fb1..26dd4f6dd 100644 --- a/config/util/representation-conversion/converters/dynamic-json-template.json +++ b/config/util/representation-conversion/converters/dynamic-json-template.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Uses the JSON data as parameters for a template.", diff --git a/config/util/representation-conversion/converters/errors.json b/config/util/representation-conversion/converters/errors.json index 17eb62f7a..800965ed8 100644 --- a/config/util/representation-conversion/converters/errors.json +++ b/config/util/representation-conversion/converters/errors.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "@id": "urn:solid-server:default:ErrorToJsonConverter", diff --git a/config/util/representation-conversion/converters/form-to-json.json b/config/util/representation-conversion/converters/form-to-json.json index fc991ffd6..4122f7402 100644 --- a/config/util/representation-conversion/converters/form-to-json.json +++ b/config/util/representation-conversion/converters/form-to-json.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts application/x-www-form-urlencoded to application/json.", diff --git a/config/util/representation-conversion/converters/markdown.json b/config/util/representation-conversion/converters/markdown.json index 183ce27be..45f2be4cd 100644 --- a/config/util/representation-conversion/converters/markdown.json +++ b/config/util/representation-conversion/converters/markdown.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Renders Markdown snippets into the main page template.", diff --git a/config/util/representation-conversion/converters/quad-to-rdf.json b/config/util/representation-conversion/converters/quad-to-rdf.json index 90221247c..3d884aeac 100644 --- a/config/util/representation-conversion/converters/quad-to-rdf.json +++ b/config/util/representation-conversion/converters/quad-to-rdf.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts Quad objects to many RDF serialization.", diff --git a/config/util/representation-conversion/converters/rdf-to-quad.json b/config/util/representation-conversion/converters/rdf-to-quad.json index c19ca71b1..2ce2a96ca 100644 --- a/config/util/representation-conversion/converters/rdf-to-quad.json +++ b/config/util/representation-conversion/converters/rdf-to-quad.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Converts many RDF serialization to Quad objects.", diff --git a/config/util/representation-conversion/default.json b/config/util/representation-conversion/default.json index 3731ff557..c2045d07b 100644 --- a/config/util/representation-conversion/default.json +++ b/config/util/representation-conversion/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/util/representation-conversion/converters/content-type-replacer.json", "files-scs:config/util/representation-conversion/converters/dynamic-json-template.json", diff --git a/config/util/resource-locker/debug-void.json b/config/util/resource-locker/debug-void.json index c02f6e129..7776b2670 100644 --- a/config/util/resource-locker/debug-void.json +++ b/config/util/resource-locker/debug-void.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": [ diff --git a/config/util/resource-locker/memory.json b/config/util/resource-locker/memory.json index ac9c73201..a4e7a2829 100644 --- a/config/util/resource-locker/memory.json +++ b/config/util/resource-locker/memory.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Allows multiple simultaneous read operations. Locks are stored in memory. Locks expire after inactivity.", diff --git a/config/util/resource-locker/redis.json b/config/util/resource-locker/redis.json index 118150846..9f3ee717e 100644 --- a/config/util/resource-locker/redis.json +++ b/config/util/resource-locker/redis.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@graph": [ { "comment": "Allows multiple simultaneous read operations. Locks are stored in memory. Locks expire after inactivity.", diff --git a/config/util/variables/default.json b/config/util/variables/default.json index beb5aabb3..09f34c94c 100644 --- a/config/util/variables/default.json +++ b/config/util/variables/default.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "comment": "Variables used throughout the other configs. Can be set using the CLI.", "@graph": [ { diff --git a/package.json b/package.json index 9f0cfa30c..f2a99cdc8 100644 --- a/package.json +++ b/package.json @@ -20,13 +20,13 @@ "lsd:module": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server", "lsd:components": "dist/components/components.jsonld", "lsd:contexts": { - "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld": "dist/components/context.jsonld" + "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld": "dist/components/context.jsonld" }, "lsd:importPaths": { - "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/": "dist/components/", - "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/config/": "config/", - "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/dist/": "dist/", - "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/templates/config/": "templates/config/" + "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/": "dist/components/", + "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/config/": "config/", + "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/dist/": "dist/", + "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/templates/config/": "templates/config/" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/templates/config/defaults.json b/templates/config/defaults.json index e187cd83c..a3b041e0f 100644 --- a/templates/config/defaults.json +++ b/templates/config/defaults.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/util/auxiliary/acl.json", "files-scs:config/util/index/default.json", diff --git a/templates/config/filesystem.json b/templates/config/filesystem.json index 14a5cd5a3..655a9f003 100644 --- a/templates/config/filesystem.json +++ b/templates/config/filesystem.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:templates/config/defaults.json" ], diff --git a/templates/config/memory.json b/templates/config/memory.json index debc1c4a6..42cc1ebeb 100644 --- a/templates/config/memory.json +++ b/templates/config/memory.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:templates/config/defaults.json" ], diff --git a/test/integration/config/ldp-with-auth.json b/test/integration/config/ldp-with-auth.json index b3c60f09f..4ef09bd4d 100644 --- a/test/integration/config/ldp-with-auth.json +++ b/test/integration/config/ldp-with-auth.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", diff --git a/test/integration/config/quota-global.json b/test/integration/config/quota-global.json index b23a80f38..10d4116bf 100644 --- a/test/integration/config/quota-global.json +++ b/test/integration/config/quota-global.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", diff --git a/test/integration/config/quota-pod.json b/test/integration/config/quota-pod.json index d2497746f..0e5157a01 100644 --- a/test/integration/config/quota-pod.json +++ b/test/integration/config/quota-pod.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", diff --git a/test/integration/config/restricted-idp.json b/test/integration/config/restricted-idp.json index 37ecdf923..289334e78 100644 --- a/test/integration/config/restricted-idp.json +++ b/test/integration/config/restricted-idp.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", diff --git a/test/integration/config/run-with-redlock.json b/test/integration/config/run-with-redlock.json index d61ebfa24..ae8164828 100644 --- a/test/integration/config/run-with-redlock.json +++ b/test/integration/config/run-with-redlock.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", diff --git a/test/integration/config/server-dynamic-unsafe.json b/test/integration/config/server-dynamic-unsafe.json index 60c15858d..3cb0ae6a4 100644 --- a/test/integration/config/server-dynamic-unsafe.json +++ b/test/integration/config/server-dynamic-unsafe.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", diff --git a/test/integration/config/server-memory.json b/test/integration/config/server-memory.json index c45937399..1da805998 100644 --- a/test/integration/config/server-memory.json +++ b/test/integration/config/server-memory.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", diff --git a/test/integration/config/server-middleware.json b/test/integration/config/server-middleware.json index 112f03a4f..b7e3bc8be 100644 --- a/test/integration/config/server-middleware.json +++ b/test/integration/config/server-middleware.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/http/handler/simple.json", "files-scs:config/http/middleware/websockets.json", diff --git a/test/integration/config/server-subdomains-unsafe.json b/test/integration/config/server-subdomains-unsafe.json index 6d701ef4a..ebe811732 100644 --- a/test/integration/config/server-subdomains-unsafe.json +++ b/test/integration/config/server-subdomains-unsafe.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", diff --git a/test/integration/config/server-without-auth.json b/test/integration/config/server-without-auth.json index 60a5b38c0..5b9bdbf12 100644 --- a/test/integration/config/server-without-auth.json +++ b/test/integration/config/server-without-auth.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/initialize-root.json", diff --git a/test/integration/config/setup-memory.json b/test/integration/config/setup-memory.json index c4afb8bd9..929547e92 100644 --- a/test/integration/config/setup-memory.json +++ b/test/integration/config/setup-memory.json @@ -1,5 +1,5 @@ { - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "import": [ "files-scs:config/app/main/default.json", "files-scs:config/app/init/default.json", From 0dcd6d227e5b958115ca1376cb8a3b640b8c4ac4 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 23 Feb 2022 11:10:36 +0100 Subject: [PATCH 39/39] Release version 3.0.0 of the npm package. --- CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f22b190e..9ca6bb74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,47 @@ # Changelog All notable changes to this project will be documented in this file. + +## [v3.0.0](https://github.com/solid/community-server/compare/v2.0.1...v3.0.0) - 2022-02-23 + +### Added +* [feat: Determine Typed Converter output based on input type](https://github.com/solid/community-server/commit/fa94c7d4bb0d67b0cde264f9515260293b3b904a) +* [feat: Add ContentTypeReplacer to conversion chain](https://github.com/solid/community-server/commit/fdd42bb7b3efda8bfac535ef4ff07f45ea4a524a) +* [feat: Add "no conversion" as possible path in ChainedConverter](https://github.com/solid/community-server/commit/d52aa94e535768c183589179462af95814b51094) +* [feat: Support redirection through errors](https://github.com/solid/community-server/commit/7163a0317b80535ba85e636495cb48b61bb6e6f3) +* [feat: Move redirect support from IDP handler to specific handlers](https://github.com/solid/community-server/commit/4241c5348df880646ac39d34d0f733a0743fcb24) +* [feat: Create VoidLocker to disable locking resources](https://github.com/solid/community-server/commit/9a1f324685216bd6346fb19e626dcca5145053df) +* [chore: Build and push official docker image in CI](https://github.com/solid/community-server/commit/65d1eeb0a2f3ab253efca50d98d6a14c3fa3103c) +* [feat: Add support for quota limits](https://github.com/solid/community-server/commit/0cb4d7b16114ce9d0d4c5ae0766b4e4e944af9cf) +* [feat: Add support for N3 Patch](https://github.com/solid/community-server/commit/a9941ebe7880cc9bb136786d721c1ba76bda888a) +* [feat: Allow for custom CLI and variable options](https://github.com/solid/community-server/commit/c216efd62fcc05aa1db5a0046c3dbc512e7f2d62) +* [feat: Send reset password recordId as query parameter](https://github.com/solid/community-server/commit/8f8e8e6df4a4a5d8759c95c2a07e457050830ed6) +* [feat: Split up IDP HTML, routing, and handler behaviour](https://github.com/solid/community-server/commit/bc0eeb1012e15e9e9ee0f9085be209f6a9229ccd) +* [feat: Update IDP templates to work with new API format](https://github.com/solid/community-server/commit/a684b2ead7365b9409d7f2f4cfa6755e8b951958) +* [feat: Simplify setup to be more in line with IDP behaviour](https://github.com/solid/community-server/commit/95777914729890debe0d4815c084029864afaf23) +* [feat: Return client information from consent handler](https://github.com/solid/community-server/commit/e604c0c2e427f7cf426cda6e3a52c2d72b997057) +* [feat: Warn users when they change the base URL](https://github.com/solid/community-server/commit/62e22100238f1b9dfb13b9f350fccf12184f728b) +* [feat: Store the server version on start](https://github.com/solid/community-server/commit/2dc20fe3bc63da1d0a39720410da07f316b253ac) + +### Changed +* [refactor: Create BaseTypedRepresentationConverter](https://github.com/solid/community-server/commit/27306d6e3f6f3dda09914e078151a8d07e111869) +* [feat: Update IDP parameters to latest Solid-OIDC version](https://github.com/solid/community-server/commit/fc60b5c161853845d1f3e6405e1182948cca421b) +* [feat: Move OIDC library behaviour to separate path](https://github.com/solid/community-server/commit/520e4fe42fe14ec80ef0718c7f1214620fdae218) +* [fix: Update OIDC provider dependency to v7](https://github.com/solid/community-server/commit/c9ed90aeebaabca957ae1980738f732e5472ee9d) + +### Fixed +* [fix: Prefer all inputs equally when generating quads](https://github.com/solid/community-server/commit/c6544fac1db432d1e0ce323bf439c48a7ed5dc52) +* [fix: Handle JSON preferences correctly in dynamic converter](https://github.com/solid/community-server/commit/4d319d2564e953514c94cbadf93e28fefc501e86) +* [fix: Make UnionCredentialsExtractor tolerate failures.](https://github.com/solid/community-server/commit/c13456c2259538e502a59ce73a226bab2c99c395) +* [fix: Accept lowercase Authorization tokens.](https://github.com/solid/community-server/commit/9c52011addde6cbdfd22efeb9485841c640363be) +* [feat: Return correct status codes for invalid requests](https://github.com/solid/community-server/commit/1afed65368f98f4fda7bdd8f9fc5071f51d4dc5b) +* [fix: Split AccountStorage and ForgotPasswordStorage (expiring now)](https://github.com/solid/community-server/commit/d067165b68a824143ff65f289d8a1e5e53d15103) +* [fix: Add content-negotiation when fetching dataset from url](https://github.com/solid/community-server/commit/ce754c119fb87dc8a4f79c639e316bd04d40109b) +* [fix: Prevent login page from showing error before redirect](https://github.com/solid/community-server/commit/1ed45c8903e8750b818885cb6e48183e4c36f22a) +* [fix: Make IDP routes independent of handlers](https://github.com/solid/community-server/commit/1769b799df090a036f2d2925c06ba8d9f7130e6b) +* [fix: Improve OIDC error descriptions](https://github.com/solid/community-server/commit/e9e3c6df3c945e187ae351f15bfe1a6df75e47a9) + + ## [v2.0.1](https://github.com/solid/community-server/compare/v2.0.0...v2.0.1) - 2021-11-02 diff --git a/package-lock.json b/package-lock.json index 7c54daae0..c58a0d663 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@solid/community-server", - "version": "2.0.1", + "version": "3.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@solid/community-server", - "version": "2.0.1", + "version": "3.0.0", "license": "MIT", "dependencies": { "@comunica/actor-init-sparql": "^1.22.3", diff --git a/package.json b/package.json index f2a99cdc8..8936e891e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@solid/community-server", - "version": "2.0.1", + "version": "3.0.0", "description": "Community Solid Server: an open and modular implementation of the Solid specifications", "keywords": [ "solid",