feat: Split up IDP HTML, routing, and handler behaviour

This commit is contained in:
Joachim Van Herwegen 2021-12-02 09:57:23 +01:00
parent 8f8e8e6df4
commit bc0eeb1012
45 changed files with 1013 additions and 716 deletions

View File

@ -39,11 +39,9 @@
"comment": "Handles IDP handler behaviour.", "comment": "Handles IDP handler behaviour.",
"@id": "urn:solid-server:default:IdentityProviderHttpHandler", "@id": "urn:solid-server:default:IdentityProviderHttpHandler",
"@type": "IdentityProviderHttpHandler", "@type": "IdentityProviderHttpHandler",
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"args_idpPath": "/idp",
"args_providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }, "args_providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" },
"args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, "args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" } "args_handler": { "@id": "urn:solid-server:default:InteractionHandler" }
} }
] ]
} }

View File

@ -1,19 +1,45 @@
{ {
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
"import": [ "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/forgot-password.json",
"files-scs:config/identity/handler/interaction/routes/login.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/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": [ "@graph": [
{ {
"@id": "urn:solid-server:default:IdentityProviderHttpHandler", "@id": "urn:solid-server:default:InteractionHandler",
"IdentityProviderHttpHandler:_args_interactionRoutes": [ "@type": "WaterfallHandler",
{ "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" }, "handlers": [
{ "@id": "urn:solid-server:auth:password:LoginRoute" }, {
{ "@id": "urn:solid-server:auth:password:ResetPasswordRoute" }, "comment": "Returns the relevant HTML pages for the interactions when needed",
{ "@id": "urn:solid-server:auth:password:SessionRoute" } "@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" }
]
}
}
] ]
} }
] ]

View File

@ -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" }
}
}
]
}

View File

@ -2,32 +2,20 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
"@graph": [ "@graph": [
{ {
"comment": "Handles all functionality on the forgot password page", "comment": "Handles the forgot password interaction",
"@id": "urn:solid-server:auth:password:ForgotPasswordRoute", "@id": "urn:solid-server:auth:password:ForgotPasswordRoute",
"@type": "BasicInteractionRoute", "@type": "RelativeInteractionRoute",
"route": "^/forgotpassword/$", "base": { "@id": "urn:solid-server:default:variable:baseUrl" },
"viewTemplates": { "relativePath": "/idp/forgotpassword/",
"BasicInteractionRoute:_viewTemplates_key": "text/html", "source": {
"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": "ForgotPasswordHandler", "@type": "ForgotPasswordHandler",
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"args_idpPath": "/idp",
"args_templateEngine": { "args_templateEngine": {
"@type": "EjsTemplateEngine", "@type": "EjsTemplateEngine",
"template": "@css:templates/identity/email-password/reset-password-email.html.ejs" "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" }
} }
} }
] ]

View File

@ -2,20 +2,12 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
"@graph": [ "@graph": [
{ {
"comment": "Handles all functionality on the Login Page", "comment": "Handles the login interaction",
"@id": "urn:solid-server:auth:password:LoginRoute", "@id": "urn:solid-server:auth:password:LoginRoute",
"@type": "BasicInteractionRoute", "@type": "RelativeInteractionRoute",
"route": "^/login/$", "base": { "@id": "urn:solid-server:default:variable:baseUrl" },
"prompt": "login", "relativePath": "/idp/login/",
"viewTemplates": { "source": {
"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": "LoginHandler", "@type": "LoginHandler",
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
"interactionCompleter": { "@type": "BaseInteractionCompleter" } "interactionCompleter": { "@type": "BaseInteractionCompleter" }

View File

@ -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" }
}
]
}
}
]
}

View File

@ -1,21 +1,13 @@
{ {
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", "@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": [ "@graph": [
{ {
"comment": "Handles the reset password page submission", "comment": "Handles the reset password interaction",
"@id": "urn:solid-server:auth:password:ResetPasswordRoute", "@id": "urn:solid-server:auth:password:ResetPasswordRoute",
"@type": "BasicInteractionRoute", "@type": "RelativeInteractionRoute",
"route": "^/resetpassword/$", "base": { "@id": "urn:solid-server:default:variable:baseUrl" },
"viewTemplates": { "relativePath": "/idp/resetpassword/",
"BasicInteractionRoute:_viewTemplates_key": "text/html", "source": {
"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": "ResetPasswordHandler", "@type": "ResetPasswordHandler",
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" } "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }
} }

View File

@ -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" }
}
}
]
}

View File

@ -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" }
}
]
}
]
}

View File

@ -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" }
}
]
}
]
}

View File

@ -5,11 +5,35 @@
], ],
"@graph": [ "@graph": [
{ {
"comment": "Enable registration by adding a registration handler to the list of interaction routes.", "@id": "urn:solid-server:auth:password:RouteInteractionHandler",
"@id": "urn:solid-server:default:IdentityProviderHttpHandler", "WaterfallHandler:_handlers": [
"IdentityProviderHttpHandler:_args_interactionRoutes": [ {
"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: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" }
}
]
} }
] ]
} }

View File

@ -2,23 +2,12 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
"@graph": [ "@graph": [
{ {
"comment": "Handles all functionality on the register page", "comment": "Handles the register interaction",
"@id": "urn:solid-server:auth:password:RegistrationRoute", "@id": "urn:solid-server:auth:password:RegistrationRoute",
"@type": "BasicInteractionRoute", "@type": "RelativeInteractionRoute",
"route": "^/register/$", "base": { "@id": "urn:solid-server:default:variable:baseUrl" },
"viewTemplates": { "relativePath": "/idp/register/",
"BasicInteractionRoute:_viewTemplates_key": "text/html", "source": {
"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": "RegistrationHandler", "@type": "RegistrationHandler",
"registrationManager": { "registrationManager": {
"@type": "RegistrationManager", "@type": "RegistrationManager",

View File

@ -1,211 +1,81 @@
import type { Operation } from '../http/Operation'; import { OkResponseDescription } from '../http/output/response/OkResponseDescription';
import type { ErrorHandler } from '../http/output/error/ErrorHandler'; import type { ResponseDescription } from '../http/output/response/ResponseDescription';
import { ResponseDescription } from '../http/output/response/ResponseDescription';
import { BasicRepresentation } from '../http/representation/BasicRepresentation';
import { getLoggerFor } from '../logging/LogUtil'; import { getLoggerFor } from '../logging/LogUtil';
import type { OperationHttpHandlerInput } from '../server/OperationHttpHandler'; import type { OperationHttpHandlerInput } from '../server/OperationHttpHandler';
import { OperationHttpHandler } from '../server/OperationHttpHandler'; import { OperationHttpHandler } from '../server/OperationHttpHandler';
import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter'; import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter';
import { APPLICATION_JSON } from '../util/ContentTypes'; 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 { ProviderFactory } from './configuration/ProviderFactory';
import type { Interaction } from './interaction/InteractionHandler'; import type {
import type { InteractionRoute, TemplatedInteractionResult } from './interaction/routing/InteractionRoute'; InteractionHandler,
Interaction,
const API_VERSION = '0.2'; } from './interaction/InteractionHandler';
export interface IdentityProviderHttpHandlerArgs { 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. * Used to generate the OIDC provider.
*/ */
providerFactory: ProviderFactory; providerFactory: ProviderFactory;
/** /**
* All routes handling the custom IDP behaviour. * Used for converting the input data.
*/
interactionRoutes: InteractionRoute[];
/**
* Used for content negotiation.
*/ */
converter: RepresentationConverter; converter: RepresentationConverter;
/** /**
* Used for converting output errors. * Handles the requests.
*/ */
errorHandler: ErrorHandler; handler: InteractionHandler;
} }
/** /**
* Handles all requests relevant for the entire IDP interaction, * Generates the active Interaction object if there is an ongoing OIDC interaction
* by sending them to either a matching {@link InteractionRoute}, * and sends it to the {@link InteractionHandler}.
* or the generated Provider from the {@link ProviderFactory} if there is no match.
* *
* The InteractionRoutes handle all requests where we need custom behaviour, * Input data will first be converted to JSON.
* such as everything related to generating and validating an account.
* The Provider handles all the default request such as the initial handshake.
* *
* This handler handles all requests since it assumes all those requests are relevant for the IDP interaction. * Only GET and POST methods are accepted.
* A {@link RouterHandler} should be used to filter out other requests.
*/ */
export class IdentityProviderHttpHandler extends OperationHttpHandler { export class IdentityProviderHttpHandler extends OperationHttpHandler {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
private readonly baseUrl: string;
private readonly providerFactory: ProviderFactory; private readonly providerFactory: ProviderFactory;
private readonly interactionRoutes: InteractionRoute[];
private readonly converter: RepresentationConverter; private readonly converter: RepresentationConverter;
private readonly errorHandler: ErrorHandler; private readonly handler: InteractionHandler;
private readonly controls: Record<string, string>;
public constructor(args: IdentityProviderHttpHandlerArgs) { public constructor(args: IdentityProviderHttpHandlerArgs) {
super(); 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.providerFactory = args.providerFactory;
this.interactionRoutes = args.interactionRoutes;
this.converter = args.converter; this.converter = args.converter;
this.errorHandler = args.errorHandler; this.handler = args.handler;
this.controls = Object.assign(
{},
...this.interactionRoutes.map((route): Record<string, string> => this.getRouteControls(route)),
);
} }
/**
* Finds the matching route and resolves the operation.
*/
public async handle({ operation, request, response }: OperationHttpHandlerInput): Promise<ResponseDescription> { public async handle({ operation, request, response }: OperationHttpHandlerInput): Promise<ResponseDescription> {
// This being defined means we're in an OIDC session // This being defined means we're in an OIDC session
let oidcInteraction: Interaction | undefined; let oidcInteraction: Interaction | undefined;
try { try {
const provider = await this.providerFactory.getProvider(); const provider = await this.providerFactory.getProvider();
oidcInteraction = await provider.interactionDetails(request, response); oidcInteraction = await provider.interactionDetails(request, response);
this.logger.debug('Found an active OIDC interaction.');
} catch { } catch {
// Just a regular request this.logger.debug('No active OIDC interaction found.');
} }
const route = await this.findRoute(operation, oidcInteraction); // Convert input data to JSON
if (!route) { // Allows us to still support form data
throw new NotFoundHttpError(); const { contentType } = operation.body.metadata;
} if (contentType && contentType !== APPLICATION_JSON) {
this.logger.debug(`Converting input ${contentType} to ${APPLICATION_JSON}`);
// 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) {
const args = { const args = {
representation: operation.body, representation: operation.body,
preferences: { type: { [APPLICATION_JSON]: 1 }}, preferences: { type: { [APPLICATION_JSON]: 1 }},
identifier: operation.target, identifier: operation.target,
}; };
operation.body = await this.converter.handleSafe(args); operation = {
clone = await cloneRepresentation(operation.body); ...operation,
body: await this.converter.handleSafe(args),
};
} }
const result = await route.handleOperation(operation, oidcInteraction); const representation = await this.handler.handleSafe({ operation, oidcInteraction });
return new OkResponseDescription(representation.metadata, representation.data);
// 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<InteractionRoute | undefined> {
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<ResponseDescription> {
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<string, any>, operation: Operation,
templateFiles: Record<string, string>, oidcInteraction?: Interaction, statusCode = 200):
Promise<ResponseDescription> {
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<string, string> {
const entries = Object.entries(route.getControls())
.map(([ name, path ]): [ string, string ] => [ name, joinUrl(this.baseUrl, path) ]);
return Object.fromEntries(entries);
} }
} }

View File

@ -12,10 +12,15 @@ import type { AnyObject,
ErrorOut, ErrorOut,
Adapter } from 'oidc-provider'; Adapter } from 'oidc-provider';
import { Provider } from 'oidc-provider'; import { Provider } from 'oidc-provider';
import type { Operation } from '../../http/Operation';
import type { ErrorHandler } from '../../http/output/error/ErrorHandler'; import type { ErrorHandler } from '../../http/output/error/ErrorHandler';
import type { ResponseWriter } from '../../http/output/ResponseWriter'; import type { ResponseWriter } from '../../http/output/ResponseWriter';
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage'; 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 { AdapterFactory } from '../storage/AdapterFactory';
import type { ProviderFactory } from './ProviderFactory'; import type { ProviderFactory } from './ProviderFactory';
@ -33,10 +38,9 @@ export interface IdentityProviderFactoryArgs {
*/ */
oidcPath: string; oidcPath: string;
/** /**
* The entry point for the custom IDP handlers of the server. * The handler responsible for redirecting interaction requests to the correct URL.
* Should start with a slash.
*/ */
idpPath: string; interactionHandler: InteractionHandler;
/** /**
* Storage used to store cookie and JWT keys so they can be re-used in case of multithreading. * 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. * 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. * 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. * 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 { export class IdentityProviderFactory implements ProviderFactory {
private readonly config: Configuration; private readonly config: Configuration;
private readonly adapterFactory!: AdapterFactory; private readonly adapterFactory!: AdapterFactory;
private readonly baseUrl!: string; private readonly baseUrl!: string;
private readonly oidcPath!: string; private readonly oidcPath!: string;
private readonly idpPath!: string; private readonly interactionHandler!: InteractionHandler;
private readonly storage!: KeyValueStorage<string, unknown>; private readonly storage!: KeyValueStorage<string, unknown>;
private readonly errorHandler!: ErrorHandler; private readonly errorHandler!: ErrorHandler;
private readonly responseWriter!: ResponseWriter; private readonly responseWriter!: ResponseWriter;
@ -78,9 +82,6 @@ export class IdentityProviderFactory implements ProviderFactory {
* @param args - Remaining parameters required for the factory. * @param args - Remaining parameters required for the factory.
*/ */
public constructor(config: Configuration, args: IdentityProviderFactoryArgs) { public constructor(config: Configuration, args: IdentityProviderFactoryArgs) {
if (!args.idpPath.startsWith('/')) {
throw new Error('idpPath needs to start with a /');
}
this.config = config; this.config = config;
Object.assign(this, args); Object.assign(this, args);
} }
@ -230,7 +231,26 @@ export class IdentityProviderFactory implements ProviderFactory {
// (missing user session, requested ACR not fulfilled, prompt requested, ...) // (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. // it will resolve the interactions.url helper function and redirect the User-Agent to that url.
config.interactions = { config.interactions = {
url: (): string => ensureTrailingSlash(this.idpPath), url: async(ctx, oidcInteraction): Promise<string> => {
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 = { config.routes = {
@ -254,7 +274,7 @@ export class IdentityProviderFactory implements ProviderFactory {
*/ */
private configureErrors(config: Configuration): void { private configureErrors(config: Configuration): void {
config.renderError = async(ctx: KoaContextWithOIDC, out: ErrorOut, error: Error): Promise<void> => { config.renderError = async(ctx: KoaContextWithOIDC, out: ErrorOut, error: Error): Promise<void> => {
// 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; ctx.respond = false;
const result = await this.errorHandler.handleSafe({ error, preferences: { type: { 'text/plain': 1 }}}); const result = await this.errorHandler.handleSafe({ error, preferences: { type: { 'text/plain': 1 }}});
await this.responseWriter.handleSafe({ response: ctx.res, result }); await this.responseWriter.handleSafe({ response: ctx.res, result });

View File

@ -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<string, unknown>) {
super();
this.view = JSON.stringify(view);
}
public async canHandle(input: InteractionHandlerInput): Promise<void> {
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<Representation> {
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<Representation> {
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<Representation>;
}

View File

@ -1,11 +1,11 @@
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { FoundHttpError } from '../../util/errors/FoundHttpError'; import { FoundHttpError } from '../../util/errors/FoundHttpError';
import { BaseInteractionHandler } from './BaseInteractionHandler';
import type { InteractionHandlerInput } from './InteractionHandler'; import type { InteractionHandlerInput } from './InteractionHandler';
import { InteractionHandler } from './InteractionHandler';
import type { InteractionCompleterInput, InteractionCompleter } from './util/InteractionCompleter'; 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 * This is required by handlers that handle IDP behaviour
* and need to complete an OIDC interaction by redirecting back to the client, * and need to complete an OIDC interaction by redirecting back to the client,
* such as when logging in. * 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 * Calls the InteractionCompleter with the results returned by the helper function
* and throw a corresponding {@link FoundHttpError}. * and throw a corresponding {@link FoundHttpError}.
*/ */
export abstract class CompletingInteractionHandler extends InteractionHandler { export abstract class CompletingInteractionHandler extends BaseInteractionHandler {
protected readonly interactionCompleter: InteractionCompleter; protected readonly interactionCompleter: InteractionCompleter;
protected constructor(interactionCompleter: InteractionCompleter) { protected constructor(view: Record<string, unknown>, interactionCompleter: InteractionCompleter) {
super(); super(view);
this.interactionCompleter = interactionCompleter; this.interactionCompleter = interactionCompleter;
} }
public async canHandle(input: InteractionHandlerInput): Promise<void> { public async canHandle(input: InteractionHandlerInput): Promise<void> {
await super.canHandle(input); await super.canHandle(input);
if (!input.oidcInteraction) { if (input.operation.method === 'POST' && !input.oidcInteraction) {
throw new BadRequestHttpError( throw new BadRequestHttpError(
'This action can only be performed as part of an OIDC authentication flow.', 'This action can only be performed as part of an OIDC authentication flow.',
{ errorCode: 'E0002' }, { errorCode: 'E0002' },
@ -31,7 +31,7 @@ export abstract class CompletingInteractionHandler extends InteractionHandler {
} }
} }
public async handle(input: InteractionHandlerInput): Promise<never> { public async handlePost(input: InteractionHandlerInput): Promise<never> {
// Interaction is defined due to canHandle call // Interaction is defined due to canHandle call
const parameters = await this.getCompletionParameters(input as Required<InteractionHandlerInput>); const parameters = await this.getCompletionParameters(input as Required<InteractionHandlerInput>);
const location = await this.interactionCompleter.handleSafe(parameters); 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. * 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. * @param input - The original input parameters to the `handle` function.
*/ */
protected abstract getCompletionParameters(input: Required<InteractionHandlerInput>): protected abstract getCompletionParameters(input: Required<InteractionHandlerInput>):

View File

@ -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<string, string>;
public constructor(source: InteractionHandler, controls: Record<string, InteractionRoute>) {
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<void> {
await this.source.canHandle(input);
}
public async handle(input: InteractionHandlerInput): Promise<Representation> {
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);
}
}

View File

@ -5,12 +5,12 @@ import type { InteractionHandlerInput } from './InteractionHandler';
import type { InteractionCompleter, InteractionCompleterInput } from './util/InteractionCompleter'; 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. * 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) { public constructor(interactionCompleter: InteractionCompleter) {
super(interactionCompleter); super({}, interactionCompleter);
} }
protected async getCompletionParameters({ operation, oidcInteraction }: Required<InteractionHandlerInput>): protected async getCompletionParameters({ operation, oidcInteraction }: Required<InteractionHandlerInput>):

View File

@ -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<string, string>;
public constructor(templateEngine: TemplateEngine, templates: Record<string, InteractionRoute>) {
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<void> {
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<Representation> {
const template = this.templates[operation.target.path];
const result = await this.templateEngine.render({}, { templateFile: template });
return new BasicRepresentation(result, operation.target, TEXT_HTML);
}
}

View File

@ -1,5 +1,6 @@
import type { KoaContextWithOIDC } from 'oidc-provider'; import type { KoaContextWithOIDC } from 'oidc-provider';
import type { Operation } from '../../http/Operation'; import type { Operation } from '../../http/Operation';
import type { Representation } from '../../http/representation/Representation';
import { APPLICATION_JSON } from '../../util/ContentTypes'; import { APPLICATION_JSON } from '../../util/ContentTypes';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { AsyncHandler } from '../../util/handlers/AsyncHandler'; import { AsyncHandler } from '../../util/handlers/AsyncHandler';
@ -9,7 +10,7 @@ export type Interaction = NonNullable<KoaContextWithOIDC['oidc']['entities']['In
export interface InteractionHandlerInput { export interface InteractionHandlerInput {
/** /**
* The operation to execute * The operation to execute.
*/ */
operation: Operation; operation: Operation;
/** /**
@ -19,25 +20,14 @@ export interface InteractionHandlerInput {
oidcInteraction?: Interaction; oidcInteraction?: Interaction;
} }
export type InteractionHandlerResult = InteractionResponseResult | InteractionErrorResult;
export interface InteractionResponseResult<T = NodeJS.Dict<any>> {
type: 'response';
details?: T;
}
export interface InteractionErrorResult {
type: 'error';
error: Error;
}
/** /**
* Handler used for IDP interactions. * Handler used for IDP interactions.
* Only supports JSON data. * Only supports JSON data.
*/ */
export abstract class InteractionHandler extends AsyncHandler<InteractionHandlerInput, InteractionHandlerResult> { export abstract class InteractionHandler extends AsyncHandler<InteractionHandlerInput, Representation> {
public async canHandle({ operation }: InteractionHandlerInput): Promise<void> { public async canHandle({ operation }: InteractionHandlerInput): Promise<void> {
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.'); throw new NotImplementedHttpError('Only application/json data is supported.');
} }
} }

View File

@ -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<string, InteractionRoute>;
public constructor(promptRoutes: Record<string, InteractionRoute>) {
super();
this.promptRoutes = promptRoutes;
}
public async handle({ oidcInteraction }: InteractionHandlerInput): Promise<never> {
// 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}`);
}
}

View File

@ -1,49 +1,55 @@
import assert from 'assert'; import assert from 'assert';
import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation';
import type { Representation } from '../../../../http/representation/Representation';
import { getLoggerFor } from '../../../../logging/LogUtil'; import { getLoggerFor } from '../../../../logging/LogUtil';
import { ensureTrailingSlash, joinUrl } from '../../../../util/PathUtil'; import { APPLICATION_JSON } from '../../../../util/ContentTypes';
import { readJsonStream } from '../../../../util/StreamUtil'; import { readJsonStream } from '../../../../util/StreamUtil';
import type { TemplateEngine } from '../../../../util/templates/TemplateEngine'; import type { TemplateEngine } from '../../../../util/templates/TemplateEngine';
import { InteractionHandler } from '../../InteractionHandler'; import { BaseInteractionHandler } from '../../BaseInteractionHandler';
import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler'; import type { InteractionHandlerInput } from '../../InteractionHandler';
import type { InteractionRoute } from '../../routing/InteractionRoute';
import type { EmailSender } from '../../util/EmailSender'; import type { EmailSender } from '../../util/EmailSender';
import type { AccountStore } from '../storage/AccountStore'; import type { AccountStore } from '../storage/AccountStore';
const forgotPasswordView = {
required: {
email: 'string',
},
} as const;
export interface ForgotPasswordHandlerArgs { export interface ForgotPasswordHandlerArgs {
accountStore: AccountStore; accountStore: AccountStore;
baseUrl: string;
idpPath: string;
templateEngine: TemplateEngine<{ resetLink: string }>; templateEngine: TemplateEngine<{ resetLink: string }>;
emailSender: EmailSender; emailSender: EmailSender;
resetRoute: InteractionRoute;
} }
/** /**
* Handles the submission of the ForgotPassword form * Handles the submission of the ForgotPassword form
*/ */
export class ForgotPasswordHandler extends InteractionHandler { export class ForgotPasswordHandler extends BaseInteractionHandler {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
private readonly accountStore: AccountStore; private readonly accountStore: AccountStore;
private readonly baseUrl: string;
private readonly idpPath: string;
private readonly templateEngine: TemplateEngine<{ resetLink: string }>; private readonly templateEngine: TemplateEngine<{ resetLink: string }>;
private readonly emailSender: EmailSender; private readonly emailSender: EmailSender;
private readonly resetRoute: InteractionRoute;
public constructor(args: ForgotPasswordHandlerArgs) { public constructor(args: ForgotPasswordHandlerArgs) {
super(); super(forgotPasswordView);
this.accountStore = args.accountStore; this.accountStore = args.accountStore;
this.baseUrl = ensureTrailingSlash(args.baseUrl);
this.idpPath = args.idpPath;
this.templateEngine = args.templateEngine; this.templateEngine = args.templateEngine;
this.emailSender = args.emailSender; this.emailSender = args.emailSender;
this.resetRoute = args.resetRoute;
} }
public async handle({ operation }: InteractionHandlerInput): Promise<InteractionResponseResult<{ email: string }>> { public async handlePost({ operation }: InteractionHandlerInput): Promise<Representation> {
// Validate incoming data // Validate incoming data
const { email } = await readJsonStream(operation.body.data); const { email } = await readJsonStream(operation.body.data);
assert(typeof email === 'string' && email.length > 0, 'Email required'); assert(typeof email === 'string' && email.length > 0, 'Email required');
await this.resetPassword(email); 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<void> { private async sendResetMail(recordId: string, email: string): Promise<void> {
this.logger.info(`Sending password reset to ${email}`); this.logger.info(`Sending password reset to ${email}`);
// `joinUrl` strips trailing slash when query parameter gets added const resetLink = `${this.resetRoute.getPath()}?rid=${encodeURIComponent(recordId)}`;
const resetLink = `${joinUrl(this.baseUrl, this.idpPath, 'resetpassword/')}?rid=${recordId}`;
const renderedEmail = await this.templateEngine.render({ resetLink }); const renderedEmail = await this.templateEngine.render({ resetLink });
await this.emailSender.handleSafe({ await this.emailSender.handleSafe({
recipient: email, recipient: email,

View File

@ -6,9 +6,22 @@ import { readJsonStream } from '../../../../util/StreamUtil';
import { CompletingInteractionHandler } from '../../CompletingInteractionHandler'; import { CompletingInteractionHandler } from '../../CompletingInteractionHandler';
import type { InteractionHandlerInput } from '../../InteractionHandler'; import type { InteractionHandlerInput } from '../../InteractionHandler';
import type { InteractionCompleterInput, InteractionCompleter } from '../../util/InteractionCompleter'; import type { InteractionCompleterInput, InteractionCompleter } from '../../util/InteractionCompleter';
import type { AccountStore } from '../storage/AccountStore'; 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. * Handles the submission of the Login Form and logs the user in.
* Will throw a RedirectHttpError on success. * Will throw a RedirectHttpError on success.
@ -19,12 +32,13 @@ export class LoginHandler extends CompletingInteractionHandler {
private readonly accountStore: AccountStore; private readonly accountStore: AccountStore;
public constructor(accountStore: AccountStore, interactionCompleter: InteractionCompleter) { public constructor(accountStore: AccountStore, interactionCompleter: InteractionCompleter) {
super(interactionCompleter); super(loginView, interactionCompleter);
this.accountStore = accountStore; this.accountStore = accountStore;
} }
protected async getCompletionParameters({ operation, oidcInteraction }: Required<InteractionHandlerInput>): protected async getCompletionParameters(input: Required<InteractionHandlerInput>):
Promise<InteractionCompleterInput> { Promise<InteractionCompleterInput> {
const { operation, oidcInteraction } = input;
const { email, password, remember } = await this.parseInput(operation); const { email, password, remember } = await this.parseInput(operation);
// Try to log in, will error if email/password combination is invalid // Try to log in, will error if email/password combination is invalid
const webId = await this.accountStore.authenticate(email, password); 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. * 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 }> { private async parseInput(operation: Operation): Promise<LoginInput> {
const prefilled: Record<string, string> = {};
const { email, password, remember } = await readJsonStream(operation.body.data); const { email, password, remember } = await readJsonStream(operation.body.data);
assert(typeof email === 'string' && email.length > 0, 'Email required'); assert(typeof email === 'string' && email.length > 0, 'Email required');
prefilled.email = email;
assert(typeof password === 'string' && password.length > 0, 'Password required'); assert(typeof password === 'string' && password.length > 0, 'Password required');
return { email, password, remember: Boolean(remember) }; return { email, password, remember: Boolean(remember) };
} }

View File

@ -1,27 +1,46 @@
import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation';
import type { Representation } from '../../../../http/representation/Representation';
import { getLoggerFor } from '../../../../logging/LogUtil'; import { getLoggerFor } from '../../../../logging/LogUtil';
import { APPLICATION_JSON } from '../../../../util/ContentTypes';
import { readJsonStream } from '../../../../util/StreamUtil'; import { readJsonStream } from '../../../../util/StreamUtil';
import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler'; import { BaseInteractionHandler } from '../../BaseInteractionHandler';
import { InteractionHandler } from '../../InteractionHandler'; import type { InteractionHandlerInput } from '../../InteractionHandler';
import type { RegistrationManager, RegistrationResponse } from '../util/RegistrationManager'; 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. * Supports registration based on the `RegistrationManager` behaviour.
*/ */
export class RegistrationHandler extends InteractionHandler { export class RegistrationHandler extends BaseInteractionHandler {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
private readonly registrationManager: RegistrationManager; private readonly registrationManager: RegistrationManager;
public constructor(registrationManager: RegistrationManager) { public constructor(registrationManager: RegistrationManager) {
super(); super(registrationView);
this.registrationManager = registrationManager; this.registrationManager = registrationManager;
} }
public async handle({ operation }: InteractionHandlerInput): public async handlePost({ operation }: InteractionHandlerInput): Promise<Representation> {
Promise<InteractionResponseResult<RegistrationResponse>> {
const data = await readJsonStream(operation.body.data); const data = await readJsonStream(operation.body.data);
const validated = this.registrationManager.validateInput(data, false); const validated = this.registrationManager.validateInput(data, false);
const details = await this.registrationManager.register(validated, false); const details = await this.registrationManager.register(validated, false);
return { type: 'response', details }; return new BasicRepresentation(JSON.stringify(details), operation.target, APPLICATION_JSON);
} }
} }

View File

@ -1,26 +1,37 @@
import assert from 'assert'; import assert from 'assert';
import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation';
import type { Representation } from '../../../../http/representation/Representation';
import { getLoggerFor } from '../../../../logging/LogUtil'; import { getLoggerFor } from '../../../../logging/LogUtil';
import { APPLICATION_JSON } from '../../../../util/ContentTypes';
import { readJsonStream } from '../../../../util/StreamUtil'; import { readJsonStream } from '../../../../util/StreamUtil';
import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler'; import { BaseInteractionHandler } from '../../BaseInteractionHandler';
import { InteractionHandler } from '../../InteractionHandler'; import type { InteractionHandlerInput } from '../../InteractionHandler';
import { assertPassword } from '../EmailPasswordUtil'; import { assertPassword } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore'; import type { AccountStore } from '../storage/AccountStore';
const resetPasswordView = {
required: {
password: 'string',
confirmPassword: 'string',
recordId: 'string',
},
} as const;
/** /**
* Handles the submission of the ResetPassword form: * Resets a password if a valid `recordId` is provided,
* this is the form that is linked in the reset password email. * which should have been generated by a different handler.
*/ */
export class ResetPasswordHandler extends InteractionHandler { export class ResetPasswordHandler extends BaseInteractionHandler {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
private readonly accountStore: AccountStore; private readonly accountStore: AccountStore;
public constructor(accountStore: AccountStore) { public constructor(accountStore: AccountStore) {
super(); super(resetPasswordView);
this.accountStore = accountStore; this.accountStore = accountStore;
} }
public async handle({ operation }: InteractionHandlerInput): Promise<InteractionResponseResult> { public async handlePost({ operation }: InteractionHandlerInput): Promise<Representation> {
// Validate input data // Validate input data
const { password, confirmPassword, recordId } = await readJsonStream(operation.body.data); const { password, confirmPassword, recordId } = await readJsonStream(operation.body.data);
assert( assert(
@ -30,7 +41,7 @@ export class ResetPasswordHandler extends InteractionHandler {
assertPassword(password, confirmPassword); assertPassword(password, confirmPassword);
await this.resetPassword(recordId, password); await this.resetPassword(recordId, password);
return { type: 'response' }; return new BasicRepresentation(JSON.stringify({}), operation.target, APPLICATION_JSON);
} }
/** /**

View File

@ -1,101 +1,43 @@
import type { Operation } from '../../../http/Operation'; import type { Representation } from '../../../http/representation/Representation';
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; import { NotFoundHttpError } from '../../../util/errors/NotFoundHttpError';
import { createErrorMessage, isError } from '../../../util/errors/ErrorUtil'; import { UnsupportedAsyncHandler } from '../../../util/handlers/UnsupportedAsyncHandler';
import { InternalServerError } from '../../../util/errors/InternalServerError'; import { InteractionHandler } from '../InteractionHandler';
import { RedirectHttpError } from '../../../util/errors/RedirectHttpError'; import type { InteractionHandlerInput } from '../InteractionHandler';
import { trimTrailingSlashes } from '../../../util/PathUtil'; import type { InteractionRoute } from './InteractionRoute';
import type {
InteractionHandler,
Interaction,
} from '../InteractionHandler';
import type { InteractionRoute, TemplatedInteractionResult } from './InteractionRoute';
/** /**
* Default implementation of the InteractionRoute. * Default implementation of an InteractionHandler with an InteractionRoute.
* See function comments for specifics. *
* 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 { export class BasicInteractionRoute extends InteractionHandler implements InteractionRoute {
public readonly route: RegExp; private readonly path: string;
public readonly handler: InteractionHandler; private readonly source: InteractionHandler;
public readonly viewTemplates: Record<string, string>;
public readonly prompt?: string;
public readonly responseTemplates: Record<string, string>;
public readonly controls: Record<string, string>;
/** public constructor(path: string, source?: InteractionHandler) {
* @param route - Regex to match this route. super();
* @param viewTemplates - Templates to render on GET requests. this.path = path;
* Keys are content-types, values paths to a template. this.source = source ?? new UnsupportedAsyncHandler('This route has no associated handler.');
* @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<string, string>,
handler: InteractionHandler,
prompt?: string,
responseTemplates: Record<string, string> = {},
controls: Record<string, string> = {}) {
this.route = new RegExp(route, 'u');
this.viewTemplates = viewTemplates;
this.handler = handler;
this.prompt = prompt;
this.responseTemplates = responseTemplates;
this.controls = controls;
} }
/** public getPath(): string {
* Returns the stored controls. return this.path;
*/
public getControls(): Record<string, string> {
return this.controls;
} }
/** public async canHandle(input: InteractionHandlerInput): Promise<void> {
* Checks support by comparing the prompt if the path targets the base URL, const { target } = input.operation;
* and otherwise comparing with the stored route regular expression. const path = this.getPath();
*/ if (target.path !== path) {
public supportsPath(path: string, prompt?: string): boolean { throw new NotFoundHttpError();
// 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;
} }
return this.route.test(path); await this.source.canHandle(input);
} }
/** public async handle(input: InteractionHandlerInput): Promise<Representation> {
* GET requests return a default response result. return this.source.handle(input);
* 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<TemplatedInteractionResult> {
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}`);
}
} }
} }

View File

@ -1,33 +1,9 @@
import type { Operation } from '../../../http/Operation';
import type { Interaction, InteractionHandlerResult } from '../InteractionHandler';
export type TemplatedInteractionResult<T extends InteractionHandlerResult = InteractionHandlerResult> = T & {
templateFiles: Record<string, string>;
};
/** /**
* Handles the routing behaviour for IDP handlers. * An object with a specific path.
*/ */
export interface InteractionRoute { export interface InteractionRoute {
/** /**
* Returns the control fields that should be added to response objects. * @returns The absolute path of this route.
* Keys are control names, values are relative URL paths.
*/ */
getControls: () => Record<string, string>; getPath: () => string;
/**
* 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<TemplatedInteractionResult>;
} }

View File

@ -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);
}
}

View File

@ -147,6 +147,7 @@ export * from './identity/interaction/email-password/EmailPasswordUtil';
// Identity/Interaction/Routing // Identity/Interaction/Routing
export * from './identity/interaction/routing/BasicInteractionRoute'; export * from './identity/interaction/routing/BasicInteractionRoute';
export * from './identity/interaction/routing/InteractionRoute'; export * from './identity/interaction/routing/InteractionRoute';
export * from './identity/interaction/routing/RelativeInteractionRoute';
// Identity/Interaction/Util // Identity/Interaction/Util
export * from './identity/interaction/util/BaseEmailSender'; export * from './identity/interaction/util/BaseEmailSender';
@ -155,9 +156,13 @@ export * from './identity/interaction/util/EmailSender';
export * from './identity/interaction/util/InteractionCompleter'; export * from './identity/interaction/util/InteractionCompleter';
// Identity/Interaction // Identity/Interaction
export * from './identity/interaction/BaseInteractionHandler';
export * from './identity/interaction/CompletingInteractionHandler'; 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/InteractionHandler';
export * from './identity/interaction/SessionHttpHandler'; export * from './identity/interaction/PromptHandler';
// Identity/Ownership // Identity/Ownership
export * from './identity/ownership/NoCheckOwnershipValidator'; export * from './identity/ownership/NoCheckOwnershipValidator';

View File

@ -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<string, jest.Mocked<InteractionRoute>>;
let source: jest.Mocked<InteractionHandler>;
let handler: ControlHandler;
beforeEach(async(): Promise<void> => {
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<void> => {
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<void> => {
source.handle.mockResolvedValueOnce(new BasicRepresentation());
await expect(handler.handle(input)).rejects.toThrow(InternalServerError);
});
it('adds controls to the source response.', async(): Promise<void> => {
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/',
},
});
});
});

View File

@ -1,14 +1,12 @@
import type { Provider } from 'oidc-provider'; import type { Provider } from 'oidc-provider';
import type { Operation } from '../../../src/http/Operation'; 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 { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation';
import type { Representation } from '../../../src/http/representation/Representation'; import type { Representation } from '../../../src/http/representation/Representation';
import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata'; import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata';
import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory'; import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory';
import type { IdentityProviderHttpHandlerArgs } from '../../../src/identity/IdentityProviderHttpHandler'; import type { IdentityProviderHttpHandlerArgs } from '../../../src/identity/IdentityProviderHttpHandler';
import { IdentityProviderHttpHandler } 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 { HttpRequest } from '../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../src/server/HttpResponse'; import type { HttpResponse } from '../../../src/server/HttpResponse';
import { getBestPreference } from '../../../src/storage/conversion/ConversionUtil'; import { getBestPreference } from '../../../src/storage/conversion/ConversionUtil';
@ -16,25 +14,20 @@ import type {
RepresentationConverter, RepresentationConverter,
RepresentationConverterArgs, RepresentationConverterArgs,
} from '../../../src/storage/conversion/RepresentationConverter'; } from '../../../src/storage/conversion/RepresentationConverter';
import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; import { APPLICATION_JSON, APPLICATION_X_WWW_FORM_URLENCODED } from '../../../src/util/ContentTypes';
import { joinUrl } from '../../../src/util/PathUtil'; import { CONTENT_TYPE } from '../../../src/util/Vocabularies';
import { guardedStreamFrom, readableToString } from '../../../src/util/StreamUtil';
import { CONTENT_TYPE, SOLID_META } from '../../../src/util/Vocabularies';
describe('An IdentityProviderHttpHandler', (): void => { describe('An IdentityProviderHttpHandler', (): void => {
const apiVersion = '0.2';
const baseUrl = 'http://test.com/';
const idpPath = '/idp';
const request: HttpRequest = {} as any; const request: HttpRequest = {} as any;
const response: HttpResponse = {} as any; const response: HttpResponse = {} as any;
const oidcInteraction: Interaction = {} as any;
let operation: Operation; let operation: Operation;
let representation: Representation;
let providerFactory: jest.Mocked<ProviderFactory>; let providerFactory: jest.Mocked<ProviderFactory>;
let routes: Record<'response' | 'complete' | 'error', jest.Mocked<InteractionRoute>>;
let controls: Record<string, string>;
let converter: jest.Mocked<RepresentationConverter>; let converter: jest.Mocked<RepresentationConverter>;
let errorHandler: jest.Mocked<ErrorHandler>;
let provider: jest.Mocked<Provider>; let provider: jest.Mocked<Provider>;
let handler: IdentityProviderHttpHandler; let handler: jest.Mocked<InteractionHandler>;
let idpHandler: IdentityProviderHttpHandler;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
operation = { operation = {
@ -45,44 +38,13 @@ describe('An IdentityProviderHttpHandler', (): void => {
}; };
provider = { provider = {
interactionDetails: jest.fn(), interactionDetails: jest.fn().mockReturnValue(oidcInteraction),
} as any; } as any;
providerFactory = { providerFactory = {
getProvider: jest.fn().mockResolvedValue(provider), 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 = { converter = {
handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => { handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => {
// Just find the best match; // Just find the best match;
@ -92,91 +54,50 @@ describe('An IdentityProviderHttpHandler', (): void => {
}), }),
} as any; } as any;
errorHandler = { handleSafe: jest.fn(({ error }: ErrorHandlerArgs): ResponseDescription => ({ representation = new BasicRepresentation();
statusCode: 400, handler = {
data: guardedStreamFrom(`{ "name": "${error.name}", "message": "${error.message}" }`), handleSafe: jest.fn().mockResolvedValue(representation),
})) } as any; } as any;
const args: IdentityProviderHttpHandlerArgs = { const args: IdentityProviderHttpHandlerArgs = {
baseUrl,
idpPath,
providerFactory, providerFactory,
interactionRoutes: Object.values(routes),
converter, converter,
errorHandler, handler,
}; };
handler = new IdentityProviderHttpHandler(args); idpHandler = new IdentityProviderHttpHandler(args);
}); });
it('throws a 404 if there is no matching route.', async(): Promise<void> => { it('returns the handler result as 200 response.', async(): Promise<void> => {
operation.target.path = joinUrl(baseUrl, 'invalid'); const result = await idpHandler.handle({ operation, request, response });
await expect(handler.handle({ request, response, operation })).rejects.toThrow(NotFoundHttpError);
});
it('creates Representations for InteractionResponseResults.', async(): Promise<void> => {
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 });
expect(result.statusCode).toBe(200); expect(result.statusCode).toBe(200);
expect(result.metadata?.contentType).toBe('text/html'); expect(result.data).toBe(representation.data);
expect(result.metadata?.get(SOLID_META.template)?.value).toBe('/response'); expect(result.metadata).toBe(representation.metadata);
expect(handler.handleSafe).toHaveBeenCalledTimes(1);
expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction });
}); });
it('creates Representations for InteractionErrorResults.', async(): Promise<void> => { it('passes no interaction if the provider call failed.', async(): Promise<void> => {
operation.target.path = joinUrl(baseUrl, '/idp/routeError'); provider.interactionDetails.mockRejectedValueOnce(new Error('no interaction'));
operation.method = 'POST'; const result = await idpHandler.handle({ operation, request, response });
operation.preferences = { type: { 'text/html': 1 }}; expect(result.statusCode).toBe(200);
expect(result.data).toBe(representation.data);
const result = (await handler.handle({ request, response, operation }))!; expect(result.metadata).toBe(representation.metadata);
expect(result).toBeDefined(); expect(handler.handleSafe).toHaveBeenCalledTimes(1);
expect(routes.error.handleOperation).toHaveBeenCalledTimes(1); expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation });
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('adds a prefilled field in case error requests had a body.', async(): Promise<void> => { it('converts input bodies to JSON.', async(): Promise<void> => {
operation.target.path = joinUrl(baseUrl, '/idp/routeError'); operation.body.metadata.contentType = APPLICATION_X_WWW_FORM_URLENCODED;
operation.method = 'POST'; const result = await idpHandler.handle({ operation, request, response });
operation.preferences = { type: { 'text/html': 1 }}; expect(result.statusCode).toBe(200);
operation.body = new BasicRepresentation('{ "key": "val" }', 'application/json'); expect(result.data).toBe(representation.data);
expect(result.metadata).toBe(representation.metadata);
const result = (await handler.handle({ request, response, operation }))!; expect(handler.handleSafe).toHaveBeenCalledTimes(1);
expect(result).toBeDefined(); // eslint-disable-next-line @typescript-eslint/no-unused-vars
expect(routes.error.handleOperation).toHaveBeenCalledTimes(1); const { body, ...partialOperation } = operation;
expect(routes.error.handleOperation).toHaveBeenLastCalledWith(operation, undefined); expect(handler.handleSafe).toHaveBeenLastCalledWith(
expect(operation.body?.metadata.contentType).toBe('application/json'); { operation: expect.objectContaining(partialOperation), oidcInteraction },
expect(JSON.parse(await readableToString(result.data!))).toEqual(
{ apiVersion, name: 'Error', message: 'test error', authenticating: false, controls, prefilled: { key: 'val' }},
); );
expect(result.statusCode).toBe(400); expect(handler.handleSafe.mock.calls[0][0].operation.body.metadata.contentType).toBe(APPLICATION_JSON);
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<void> => {
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 });
}); });
}); });

View File

@ -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 { ErrorHandler } from '../../../../src/http/output/error/ErrorHandler';
import type { ResponseWriter } from '../../../../src/http/output/ResponseWriter'; import type { ResponseWriter } from '../../../../src/http/output/ResponseWriter';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import { IdentityProviderFactory } from '../../../../src/identity/configuration/IdentityProviderFactory'; 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 { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory';
import type { HttpResponse } from '../../../../src/server/HttpResponse'; import type { HttpResponse } from '../../../../src/server/HttpResponse';
import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage';
import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
jest.mock('oidc-provider', (): any => ({ jest.mock('oidc-provider', (): any => ({
@ -28,10 +31,13 @@ const routes = {
describe('An IdentityProviderFactory', (): void => { describe('An IdentityProviderFactory', (): void => {
let baseConfig: Configuration; let baseConfig: Configuration;
const baseUrl = 'http://test.com/foo/'; const baseUrl = 'http://example.com/foo/';
const oidcPath = '/oidc'; const oidcPath = '/oidc';
const idpPath = '/idp'; const webId = 'http://alice.example.com/card#me';
const webId = 'http://alice.test.com/card#me'; const redirectUrl = 'http://example.com/login/';
const oidcInteraction: Interaction = {} as any;
let ctx: KoaContextWithOIDC;
let interactionHandler: jest.Mocked<InteractionHandler>;
let adapterFactory: jest.Mocked<AdapterFactory>; let adapterFactory: jest.Mocked<AdapterFactory>;
let storage: jest.Mocked<KeyValueStorage<string, any>>; let storage: jest.Mocked<KeyValueStorage<string, any>>;
let errorHandler: jest.Mocked<ErrorHandler>; let errorHandler: jest.Mocked<ErrorHandler>;
@ -41,6 +47,17 @@ describe('An IdentityProviderFactory', (): void => {
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
baseConfig = { claims: { webid: [ 'webid', 'client_webid' ]}}; 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 = { adapterFactory = {
createStorageAdapter: jest.fn().mockReturnValue('adapter!'), createStorageAdapter: jest.fn().mockReturnValue('adapter!'),
}; };
@ -61,25 +78,13 @@ describe('An IdentityProviderFactory', (): void => {
adapterFactory, adapterFactory,
baseUrl, baseUrl,
oidcPath, oidcPath,
idpPath, interactionHandler,
storage, storage,
errorHandler, errorHandler,
responseWriter, responseWriter,
}); });
}); });
it('errors if the idpPath parameter does not start with a slash.', async(): Promise<void> => {
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<void> => { it('creates a correct configuration.', async(): Promise<void> => {
// This is the output of our mock function // This is the output of our mock function
const provider = await factory.getProvider() as any; 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.jwks).toEqual({ keys: [ expect.objectContaining({ kty: 'RSA' }) ]});
expect(config.routes).toEqual(routes); 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, {}, 'access_token')).toBe('solid');
expect((config.audiences as any)(null, null, { clientId: 'clientId' }, 'client_credentials')).toBe('clientId'); 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 }}); expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }});
}); });
it('errors if there is no valid interaction redirect.', async(): Promise<void> => {
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<void> => { it('copies a field from the input config if values need to be added to it.', async(): Promise<void> => {
baseConfig.cookies = { baseConfig.cookies = {
long: { signed: true }, long: { signed: true },
@ -131,7 +147,7 @@ describe('An IdentityProviderFactory', (): void => {
adapterFactory, adapterFactory,
baseUrl, baseUrl,
oidcPath, oidcPath,
idpPath, interactionHandler,
storage, storage,
errorHandler, errorHandler,
responseWriter, responseWriter,
@ -153,7 +169,7 @@ describe('An IdentityProviderFactory', (): void => {
adapterFactory, adapterFactory,
baseUrl, baseUrl,
oidcPath, oidcPath,
idpPath, interactionHandler,
storage, storage,
errorHandler, errorHandler,
responseWriter, responseWriter,

View File

@ -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<Representation> {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
const operation: Operation = {
method: 'DELETE',
target: { path: 'http://example.com/foo' },
body: new BasicRepresentation(),
preferences: {},
};
await expect(handler.handle({ operation })).rejects.toThrow(MethodNotAllowedHttpError);
});
});

View File

@ -1,7 +1,10 @@
import type { Operation } from '../../../../src/http/Operation'; import type { Operation } from '../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import { CompletingInteractionHandler } from '../../../../src/identity/interaction/CompletingInteractionHandler'; 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 { import type {
InteractionCompleter, InteractionCompleter,
InteractionCompleterInput, InteractionCompleterInput,
@ -11,7 +14,7 @@ import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplemen
const webId = 'http://alice.test.com/card#me'; const webId = 'http://alice.test.com/card#me';
class DummyCompletingInteractionHandler extends CompletingInteractionHandler { class DummyCompletingInteractionHandler extends CompletingInteractionHandler {
public constructor(interactionCompleter: InteractionCompleter) { public constructor(interactionCompleter: InteractionCompleter) {
super(interactionCompleter); super({}, interactionCompleter);
} }
public async getCompletionParameters(input: Required<InteractionHandlerInput>): Promise<InteractionCompleterInput> { public async getCompletionParameters(input: Required<InteractionHandlerInput>): Promise<InteractionCompleterInput> {
@ -28,7 +31,10 @@ describe('A CompletingInteractionHandler', (): void => {
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
const representation = new BasicRepresentation('', 'application/json'); const representation = new BasicRepresentation('', 'application/json');
operation = { body: representation } as any; operation = {
method: 'POST',
body: representation,
} as any;
interactionCompleter = { interactionCompleter = {
handleSafe: jest.fn().mockResolvedValue(location), handleSafe: jest.fn().mockResolvedValue(location),
@ -39,10 +45,15 @@ describe('A CompletingInteractionHandler', (): void => {
it('calls the parent JSON canHandle check.', async(): Promise<void> => { it('calls the parent JSON canHandle check.', async(): Promise<void> => {
operation.body.metadata.contentType = 'application/x-www-form-urlencoded'; 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<void> => { it('can handle GET requests without interaction.', async(): Promise<void> => {
operation.method = 'GET';
await expect(handler.canHandle({ operation } as any)).resolves.toBeUndefined();
});
it('errors if no OidcInteraction is defined on POST requests.', async(): Promise<void> => {
const error = expect.objectContaining({ const error = expect.objectContaining({
statusCode: 400, statusCode: 400,
message: 'This action can only be performed as part of an OIDC authentication flow.', message: 'This action can only be performed as part of an OIDC authentication flow.',

View File

@ -1,27 +1,17 @@
import type { InteractionHandlerInput, Interaction } from '../../../../src/identity/interaction/InteractionHandler'; import { ExistingLoginHandler } from '../../../../src/identity/interaction/ExistingLoginHandler';
import { SessionHttpHandler } from '../../../../src/identity/interaction/SessionHttpHandler'; import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler';
import type { import type {
InteractionCompleter, InteractionCompleter,
InteractionCompleterInput,
} from '../../../../src/identity/interaction/util/InteractionCompleter'; } from '../../../../src/identity/interaction/util/InteractionCompleter';
import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { createPostJsonOperation } from './email-password/handler/Util'; import { createPostJsonOperation } from './email-password/handler/Util';
class PublicSessionHttpHandler extends SessionHttpHandler { describe('An ExistingLoginHandler', (): void => {
public constructor(interactionCompleter: InteractionCompleter) {
super(interactionCompleter);
}
public async getCompletionParameters(input: Required<InteractionHandlerInput>): Promise<InteractionCompleterInput> {
return super.getCompletionParameters(input);
}
}
describe('A SessionHttpHandler', (): void => {
const webId = 'http://test.com/id#me'; const webId = 'http://test.com/id#me';
let oidcInteraction: Interaction; let oidcInteraction: Interaction;
let interactionCompleter: jest.Mocked<InteractionCompleter>; let interactionCompleter: jest.Mocked<InteractionCompleter>;
let handler: PublicSessionHttpHandler; let handler: ExistingLoginHandler;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
oidcInteraction = { session: { accountId: webId }} as any; oidcInteraction = { session: { accountId: webId }} as any;
@ -30,18 +20,19 @@ describe('A SessionHttpHandler', (): void => {
handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'), handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'),
} as any; } as any;
handler = new PublicSessionHttpHandler(interactionCompleter); handler = new ExistingLoginHandler(interactionCompleter);
}); });
it('requires an oidcInteraction with a defined session.', async(): Promise<void> => { it('requires an oidcInteraction with a defined session.', async(): Promise<void> => {
oidcInteraction.session = undefined; oidcInteraction.session = undefined;
await expect(handler.getCompletionParameters({ operation: {} as any, oidcInteraction })) await expect(handler.handle({ operation: createPostJsonOperation({}), oidcInteraction }))
.rejects.toThrow(NotImplementedHttpError); .rejects.toThrow(NotImplementedHttpError);
}); });
it('returns the correct completion parameters.', async(): Promise<void> => { it('returns the correct completion parameters.', async(): Promise<void> => {
const operation = createPostJsonOperation({ remember: true }); const operation = createPostJsonOperation({ remember: true });
await expect(handler.getCompletionParameters({ operation, oidcInteraction })) await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
.resolves.toEqual({ oidcInteraction, webId, shouldRemember: true }); expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1);
expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ oidcInteraction, webId, shouldRemember: true });
}); });
}); });

View File

@ -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<string, jest.Mocked<InteractionRoute>>;
let templateEngine: TemplateEngine;
let handler: HtmlViewHandler;
beforeEach(async(): Promise<void> => {
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('<html>')),
};
handler = new HtmlViewHandler(templateEngine, templates);
});
it('rejects non-GET requests.', async(): Promise<void> => {
operation.method = 'POST';
await expect(handler.canHandle({ operation })).rejects.toThrow(MethodNotAllowedHttpError);
});
it('rejects unsupported paths.', async(): Promise<void> => {
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<void> => {
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<void> => {
await expect(handler.canHandle({ operation })).resolves.toBeUndefined();
});
it('returns the resolved template.', async(): Promise<void> => {
const result = await handler.handle({ operation });
expect(result.metadata.contentType).toBe(TEXT_HTML);
await expect(readableToString(result.data)).resolves.toBe('<html>');
});
});

View File

@ -1,22 +1,20 @@
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { import type { Representation } from '../../../../src/http/representation/Representation';
InteractionResponseResult,
} from '../../../../src/identity/interaction/InteractionHandler';
import { import {
InteractionHandler, InteractionHandler,
} from '../../../../src/identity/interaction/InteractionHandler'; } from '../../../../src/identity/interaction/InteractionHandler';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
class SimpleInteractionHandler extends InteractionHandler { class SimpleInteractionHandler extends InteractionHandler {
public async handle(): Promise<InteractionResponseResult> { public async handle(): Promise<Representation> {
return { type: 'response' }; return new BasicRepresentation();
} }
} }
describe('An InteractionHandler', (): void => { describe('An InteractionHandler', (): void => {
const handler = new SimpleInteractionHandler(); const handler = new SimpleInteractionHandler();
it('only supports JSON data.', async(): Promise<void> => { it('only supports JSON data or empty bodies.', async(): Promise<void> => {
let representation = new BasicRepresentation('{}', 'application/json'); let representation = new BasicRepresentation('{}', 'application/json');
await expect(handler.canHandle({ operation: { body: representation }} as any)).resolves.toBeUndefined(); 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)) await expect(handler.canHandle({ operation: { body: representation }} as any))
.rejects.toThrow(NotImplementedHttpError); .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();
}); });
}); });

View File

@ -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<string, jest.Mocked<InteractionRoute>>;
let handler: PromptHandler;
beforeEach(async(): Promise<void> => {
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<void> => {
await expect(handler.handle({ operation })).rejects.toThrow(BadRequestHttpError);
});
it('errors if the prompt is unsupported.', async(): Promise<void> => {
oidcInteraction.prompt.name = 'unsupported';
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(BadRequestHttpError);
});
it('throws a redirect error with the correct location.', async(): Promise<void> => {
const error = expect.objectContaining({
statusCode: 302,
location: 'http://example.com/idp/login/',
});
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(error);
});
});

View File

@ -3,7 +3,9 @@ import {
ForgotPasswordHandler, ForgotPasswordHandler,
} from '../../../../../../src/identity/interaction/email-password/handler/ForgotPasswordHandler'; } from '../../../../../../src/identity/interaction/email-password/handler/ForgotPasswordHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; 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 type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender';
import { readJsonStream } from '../../../../../../src/util/StreamUtil';
import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine'; import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine';
import { createPostJsonOperation } from './Util'; import { createPostJsonOperation } from './Util';
@ -11,11 +13,10 @@ describe('A ForgotPasswordHandler', (): void => {
let operation: Operation; let operation: Operation;
const email = 'test@test.email'; const email = 'test@test.email';
const recordId = '123456'; const recordId = '123456';
const html = `<a href="/base/idp/resetpassword/${recordId}">Reset Password</a>`; const html = `<a href="/base/idp/resetpassword/?rid=${recordId}">Reset Password</a>`;
let accountStore: AccountStore; let accountStore: AccountStore;
const baseUrl = 'http://test.com/base/';
const idpPath = '/idp';
let templateEngine: TemplateEngine<{ resetLink: string }>; let templateEngine: TemplateEngine<{ resetLink: string }>;
let resetRoute: jest.Mocked<InteractionRoute>;
let emailSender: EmailSender; let emailSender: EmailSender;
let handler: ForgotPasswordHandler; let handler: ForgotPasswordHandler;
@ -30,16 +31,19 @@ describe('A ForgotPasswordHandler', (): void => {
render: jest.fn().mockResolvedValue(html), render: jest.fn().mockResolvedValue(html),
} as any; } as any;
resetRoute = {
getPath: jest.fn().mockReturnValue('http://test.com/base/idp/resetpassword/'),
} as any;
emailSender = { emailSender = {
handleSafe: jest.fn(), handleSafe: jest.fn(),
} as any; } as any;
handler = new ForgotPasswordHandler({ handler = new ForgotPasswordHandler({
accountStore, accountStore,
baseUrl,
idpPath,
templateEngine, templateEngine,
emailSender, 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<void> => { it('does not send a mail if a ForgotPassword record could not be generated.', async(): Promise<void> => {
(accountStore.generateForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('error'); (accountStore.generateForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('error');
await expect(handler.handle({ operation })).resolves const result = await handler.handle({ operation });
.toEqual({ type: 'response', details: { email }}); await expect(readJsonStream(result.data)).resolves.toEqual({ email });
expect(emailSender.handleSafe).toHaveBeenCalledTimes(0); expect(emailSender.handleSafe).toHaveBeenCalledTimes(0);
}); });
it('sends a mail if a ForgotPassword record could be generated.', async(): Promise<void> => { it('sends a mail if a ForgotPassword record could be generated.', async(): Promise<void> => {
await expect(handler.handle({ operation })).resolves const result = await handler.handle({ operation });
.toEqual({ type: 'response', details: { email }}); await expect(readJsonStream(result.data)).resolves.toEqual({ email });
expect(result.metadata.contentType).toBe('application/json');
expect(emailSender.handleSafe).toHaveBeenCalledTimes(1); expect(emailSender.handleSafe).toHaveBeenCalledTimes(1);
expect(emailSender.handleSafe).toHaveBeenLastCalledWith({ expect(emailSender.handleSafe).toHaveBeenLastCalledWith({
recipient: email, recipient: email,

View File

@ -5,22 +5,11 @@ import type {
InteractionHandlerInput, InteractionHandlerInput,
} from '../../../../../../src/identity/interaction/InteractionHandler'; } from '../../../../../../src/identity/interaction/InteractionHandler';
import type { import type {
InteractionCompleterInput,
InteractionCompleter, InteractionCompleter,
} from '../../../../../../src/identity/interaction/util/InteractionCompleter'; } from '../../../../../../src/identity/interaction/util/InteractionCompleter';
import { FoundHttpError } from '../../../../../../src/util/errors/FoundHttpError';
import { createPostJsonOperation } from './Util'; import { createPostJsonOperation } from './Util';
class PublicLoginHandler extends LoginHandler {
public constructor(accountStore: AccountStore, interactionCompleter: InteractionCompleter) {
super(accountStore, interactionCompleter);
}
public async getCompletionParameters(input: Required<InteractionHandlerInput>): Promise<InteractionCompleterInput> {
return super.getCompletionParameters(input);
}
}
describe('A LoginHandler', (): void => { describe('A LoginHandler', (): void => {
const webId = 'http://alice.test.com/card#me'; const webId = 'http://alice.test.com/card#me';
const email = 'alice@test.email'; const email = 'alice@test.email';
@ -28,7 +17,7 @@ describe('A LoginHandler', (): void => {
let input: Required<InteractionHandlerInput>; let input: Required<InteractionHandlerInput>;
let accountStore: jest.Mocked<AccountStore>; let accountStore: jest.Mocked<AccountStore>;
let interactionCompleter: jest.Mocked<InteractionCompleter>; let interactionCompleter: jest.Mocked<InteractionCompleter>;
let handler: PublicLoginHandler; let handler: LoginHandler;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
input = { oidcInteraction } as any; input = { oidcInteraction } as any;
@ -42,41 +31,43 @@ describe('A LoginHandler', (): void => {
handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'), handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'),
} as any; } as any;
handler = new PublicLoginHandler(accountStore, interactionCompleter); handler = new LoginHandler(accountStore, interactionCompleter);
}); });
it('errors on invalid emails.', async(): Promise<void> => { it('errors on invalid emails.', async(): Promise<void> => {
input.operation = createPostJsonOperation({}); 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' ]}); 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<void> => { it('errors on invalid passwords.', async(): Promise<void> => {
input.operation = createPostJsonOperation({ email }); 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' ]}); 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<void> => { it('throws an error if there is a problem.', async(): Promise<void> => {
input.operation = createPostJsonOperation({ email, password: 'password!' }); input.operation = createPostJsonOperation({ email, password: 'password!' });
accountStore.authenticate.mockRejectedValueOnce(new Error('auth failed!')); 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<void> => { it('throws an error if the account does not have the correct settings.', async(): Promise<void> => {
input.operation = createPostJsonOperation({ email, password: 'password!' }); input.operation = createPostJsonOperation({ email, password: 'password!' });
accountStore.getSettings.mockResolvedValueOnce({ useIdp: false }); 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.'); .rejects.toThrow('This server is not an identity provider for this account.');
}); });
it('returns the correct completion parameters.', async(): Promise<void> => { it('returns the correct completion parameters.', async(): Promise<void> => {
input.operation = createPostJsonOperation({ email, password: 'password!' }); input.operation = createPostJsonOperation({ email, password: 'password!' });
await expect(handler.getCompletionParameters(input)) await expect(handler.handle(input)).rejects.toThrow(FoundHttpError);
.resolves.toEqual({ oidcInteraction, webId, shouldRemember: false });
expect(accountStore.authenticate).toHaveBeenCalledTimes(1); expect(accountStore.authenticate).toHaveBeenCalledTimes(1);
expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, 'password!'); expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, 'password!');
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1);
expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ oidcInteraction, webId, shouldRemember: false });
}); });
}); });

View File

@ -5,6 +5,7 @@ import {
import type { import type {
RegistrationManager, RegistrationParams, RegistrationResponse, RegistrationManager, RegistrationParams, RegistrationResponse,
} from '../../../../../../src/identity/interaction/email-password/util/RegistrationManager'; } from '../../../../../../src/identity/interaction/email-password/util/RegistrationManager';
import { readJsonStream } from '../../../../../../src/util/StreamUtil';
import { createPostJsonOperation } from './Util'; import { createPostJsonOperation } from './Util';
describe('A RegistrationHandler', (): void => { 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<void> => { it('converts the stream to json and sends it to the registration manager.', async(): Promise<void> => {
const params = { email: 'alice@test.email', password: 'superSecret' }; const params = { email: 'alice@test.email', password: 'superSecret' };
operation = createPostJsonOperation(params); operation = createPostJsonOperation(params);
await expect(handler.handle({ operation })).resolves.toEqual({ const result = await handler.handle({ operation });
type: 'response', await expect(readJsonStream(result.data)).resolves.toEqual(details);
details, expect(result.metadata.contentType).toBe('application/json');
});
expect(registrationManager.validateInput).toHaveBeenCalledTimes(1); expect(registrationManager.validateInput).toHaveBeenCalledTimes(1);
expect(registrationManager.validateInput).toHaveBeenLastCalledWith(params, false); expect(registrationManager.validateInput).toHaveBeenLastCalledWith(params, false);

View File

@ -3,6 +3,7 @@ import {
ResetPasswordHandler, ResetPasswordHandler,
} from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordHandler'; } from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import { readJsonStream } from '../../../../../../src/util/StreamUtil';
import { createPostJsonOperation } from './Util'; import { createPostJsonOperation } from './Util';
describe('A ResetPasswordHandler', (): void => { describe('A ResetPasswordHandler', (): void => {
@ -46,7 +47,9 @@ describe('A ResetPasswordHandler', (): void => {
it('renders a message on success.', async(): Promise<void> => { it('renders a message on success.', async(): Promise<void> => {
operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!', recordId }, url); 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).toHaveBeenCalledTimes(1);
expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId); expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId);
expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1); expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1);

View File

@ -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 { import type {
InteractionHandler, InteractionHandler,
} from '../../../../../src/identity/interaction/InteractionHandler'; } from '../../../../../src/identity/interaction/InteractionHandler';
import { BasicInteractionRoute } from '../../../../../src/identity/interaction/routing/BasicInteractionRoute'; import { BasicInteractionRoute } from '../../../../../src/identity/interaction/routing/BasicInteractionRoute';
import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; import { APPLICATION_JSON } from '../../../../../src/util/ContentTypes';
import { FoundHttpError } from '../../../../../src/util/errors/FoundHttpError'; import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError';
import { InternalServerError } from '../../../../../src/util/errors/InternalServerError'; import { createPostJsonOperation } from '../email-password/handler/Util';
describe('A BasicInteractionRoute', (): void => { describe('A BasicInteractionRoute', (): void => {
const path = '^/route$'; const path = 'http://example.com/idp/path/';
const viewTemplates = { 'text/html': '/viewTemplate' }; let operation: Operation;
let handler: jest.Mocked<InteractionHandler>; let representation: Representation;
const prompt = 'login'; let source: jest.Mocked<InteractionHandler>;
const responseTemplates = { 'text/html': '/responseTemplate' };
const controls = { login: '/route' };
const response = { type: 'response' };
let route: BasicInteractionRoute; let route: BasicInteractionRoute;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
handler = { operation = createPostJsonOperation({}, 'http://example.com/idp/path/');
handleSafe: jest.fn().mockResolvedValue(response),
representation = new BasicRepresentation(JSON.stringify({}), APPLICATION_JSON);
source = {
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue(representation),
} as any; } as any;
route = new BasicInteractionRoute(path, viewTemplates, handler, prompt, responseTemplates, controls); route = new BasicInteractionRoute(path, source);
}); });
it('returns its controls.', async(): Promise<void> => { it('returns the given path.', async(): Promise<void> => {
expect(route.getControls()).toEqual(controls); expect(route.getPath()).toBe('http://example.com/idp/path/');
}); });
it('supports a path if it matches the stored route.', async(): Promise<void> => { it('rejects other paths.', async(): Promise<void> => {
expect(route.supportsPath('/route')).toBe(true); operation = createPostJsonOperation({}, 'http://example.com/idp/otherPath/');
expect(route.supportsPath('/notRoute')).toBe(false); await expect(route.canHandle({ operation })).rejects.toThrow(NotFoundHttpError);
}); });
it('supports prompts when targeting the base path.', async(): Promise<void> => { it('rejects input its source cannot handle.', async(): Promise<void> => {
expect(route.supportsPath('/', prompt)).toBe(true); source.canHandle.mockRejectedValueOnce(new Error('bad data'));
expect(route.supportsPath('/notRoute', prompt)).toBe(false); await expect(route.canHandle({ operation })).rejects.toThrow('bad data');
expect(route.supportsPath('/', 'notPrompt')).toBe(false);
}); });
it('returns a response result on a GET request.', async(): Promise<void> => { it('can handle requests its source can handle.', async(): Promise<void> => {
await expect(route.handleOperation({ method: 'GET' } as any)) await expect(route.canHandle({ operation })).resolves.toBeUndefined();
.resolves.toEqual({ type: 'response', templateFiles: viewTemplates });
}); });
it('returns the result of the InteractionHandler on POST requests.', async(): Promise<void> => { it('lets its source handle requests.', async(): Promise<void> => {
await expect(route.handleOperation({ method: 'POST' } as any)) await expect(route.handle({ operation })).resolves.toBe(representation);
.resolves.toEqual({ ...response, templateFiles: responseTemplates });
expect(handler.handleSafe).toHaveBeenCalledTimes(1);
expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation: { method: 'POST' }});
}); });
it('creates an error result in case the InteractionHandler errors.', async(): Promise<void> => { it('defaults to an UnsupportedAsyncHandler if no source is provided.', async(): Promise<void> => {
const error = new Error('bad data'); route = new BasicInteractionRoute(path);
handler.handleSafe.mockRejectedValueOnce(error); await expect(route.canHandle({ operation })).rejects.toThrow('This route has no associated handler.');
await expect(route.handleOperation({ method: 'POST' } as any)) await expect(route.handle({ operation })).rejects.toThrow('This route has no associated handler.');
.resolves.toEqual({ type: 'error', error, templateFiles: viewTemplates });
});
it('re-throws redirect errors.', async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
route = new BasicInteractionRoute(path, viewTemplates, handler, prompt);
expect(route.getControls()).toEqual({});
});
it('defaults to empty response templates.', async(): Promise<void> => {
route = new BasicInteractionRoute(path, viewTemplates, handler, prompt);
await expect(route.handleOperation({ method: 'POST' } as any)).resolves.toEqual({ ...response, templateFiles: {}});
}); });
}); });

View File

@ -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<InteractionRoute>;
let source: jest.Mocked<InteractionHandler>;
let relativeRoute: RelativeInteractionRoute;
beforeEach(async(): Promise<void> => {
route = {
getPath: jest.fn().mockReturnValue('http://example.com/'),
} as any;
source = {
canHandle: jest.fn(),
} as any;
});
it('returns the joined path.', async(): Promise<void> => {
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/');
});
});