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.",
"@id": "urn:solid-server:default:IdentityProviderHttpHandler",
"@type": "IdentityProviderHttpHandler",
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"args_idpPath": "/idp",
"args_providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" },
"args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" }
"args_handler": { "@id": "urn:solid-server:default:InteractionHandler" }
}
]
}

View File

@ -1,19 +1,45 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
"import": [
"files-scs:config/identity/handler/interaction/routes/existing-login.json",
"files-scs:config/identity/handler/interaction/routes/forgot-password.json",
"files-scs:config/identity/handler/interaction/routes/login.json",
"files-scs:config/identity/handler/interaction/routes/prompt.json",
"files-scs:config/identity/handler/interaction/routes/reset-password.json",
"files-scs:config/identity/handler/interaction/routes/session.json"
"files-scs:config/identity/handler/interaction/views/controls.json",
"files-scs:config/identity/handler/interaction/views/html.json"
],
"@graph": [
{
"@id": "urn:solid-server:default:IdentityProviderHttpHandler",
"IdentityProviderHttpHandler:_args_interactionRoutes": [
{ "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" },
"@id": "urn:solid-server:default:InteractionHandler",
"@type": "WaterfallHandler",
"handlers": [
{
"comment": "Returns the relevant HTML pages for the interactions when needed",
"@id": "urn:solid-server:auth:password:HtmlViewHandler"
},
{
"comment": "Adds controls and API version to JSON responses.",
"@id": "urn:solid-server:auth:password:ControlHandler",
"ControlHandler:_source" : {
"@id": "urn:solid-server:auth:password:RouteInteractionHandler",
"@type": "WaterfallHandler",
"handlers": [
{
"comment": [
"This handler is required to prevent Components.js issues with arrays.",
"This might be fixed in the next Components.js release after which this can be removed."
],
"@type": "UnsupportedAsyncHandler"
},
{ "@id": "urn:solid-server:auth:password:PromptRoute" },
{ "@id": "urn:solid-server:auth:password:LoginRoute" },
{ "@id": "urn:solid-server:auth:password:ResetPasswordRoute" },
{ "@id": "urn:solid-server:auth:password:SessionRoute" }
{ "@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",
"@graph": [
{
"comment": "Handles all functionality on the forgot password page",
"comment": "Handles the forgot password interaction",
"@id": "urn:solid-server:auth:password:ForgotPasswordRoute",
"@type": "BasicInteractionRoute",
"route": "^/forgotpassword/$",
"viewTemplates": {
"BasicInteractionRoute:_viewTemplates_key": "text/html",
"BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/forgot-password.html.ejs"
},
"responseTemplates": {
"BasicInteractionRoute:_responseTemplates_key": "text/html",
"BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/forgot-password-response.html.ejs"
},
"controls": {
"BasicInteractionRoute:_controls_key": "forgotPassword",
"BasicInteractionRoute:_controls_value": "/forgotpassword/"
},
"handler": {
"@type": "RelativeInteractionRoute",
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
"relativePath": "/idp/forgotpassword/",
"source": {
"@type": "ForgotPasswordHandler",
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"args_idpPath": "/idp",
"args_templateEngine": {
"@type": "EjsTemplateEngine",
"template": "@css:templates/identity/email-password/reset-password-email.html.ejs"
},
"args_emailSender": { "@id": "urn:solid-server:default:EmailSender" }
"args_emailSender": { "@id": "urn:solid-server:default:EmailSender" },
"args_resetRoute": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" }
}
}
]

View File

@ -2,20 +2,12 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles all functionality on the Login Page",
"comment": "Handles the login interaction",
"@id": "urn:solid-server:auth:password:LoginRoute",
"@type": "BasicInteractionRoute",
"route": "^/login/$",
"prompt": "login",
"viewTemplates": {
"BasicInteractionRoute:_viewTemplates_key": "text/html",
"BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/login.html.ejs"
},
"controls": {
"BasicInteractionRoute:_controls_key": "login",
"BasicInteractionRoute:_controls_value": "/login/"
},
"handler": {
"@type": "RelativeInteractionRoute",
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
"relativePath": "/idp/login/",
"source": {
"@type": "LoginHandler",
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
"interactionCompleter": { "@type": "BaseInteractionCompleter" }

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",
"comment": "Exports 2 handlers: one for viewing the page and one for doing the reset.",
"@graph": [
{
"comment": "Handles the reset password page submission",
"comment": "Handles the reset password interaction",
"@id": "urn:solid-server:auth:password:ResetPasswordRoute",
"@type": "BasicInteractionRoute",
"route": "^/resetpassword/$",
"viewTemplates": {
"BasicInteractionRoute:_viewTemplates_key": "text/html",
"BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/reset-password.html.ejs"
},
"responseTemplates": {
"BasicInteractionRoute:_responseTemplates_key": "text/html",
"BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/reset-password-response.html.ejs"
},
"handler": {
"@type": "RelativeInteractionRoute",
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
"relativePath": "/idp/resetpassword/",
"source": {
"@type": "ResetPasswordHandler",
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }
}

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": [
{
"comment": "Enable registration by adding a registration handler to the list of interaction routes.",
"@id": "urn:solid-server:default:IdentityProviderHttpHandler",
"IdentityProviderHttpHandler:_args_interactionRoutes": [
"@id": "urn:solid-server:auth:password:RouteInteractionHandler",
"WaterfallHandler:_handlers": [
{
"comment": [
"This handler is required to prevent Components.js issues with arrays.",
"This might be fixed in the next Components.js release after which this can be removed."
],
"@type": "UnsupportedAsyncHandler"
},
{ "@id": "urn:solid-server:auth:password:RegistrationRoute" }
]
},
{
"@id": "urn:solid-server:auth:password:ControlHandler",
"ControlHandler:_controls": [
{
"ControlHandler:_controls_key": "register",
"ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:RegistrationRoute" }
}
]
},
{
"@id": "urn:solid-server:auth:password:HtmlViewHandler",
"HtmlViewHandler:_templates": [
{
"HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/register.html.ejs",
"HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:RegistrationRoute" }
}
]
}
]
}

View File

@ -2,23 +2,12 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles all functionality on the register page",
"comment": "Handles the register interaction",
"@id": "urn:solid-server:auth:password:RegistrationRoute",
"@type": "BasicInteractionRoute",
"route": "^/register/$",
"viewTemplates": {
"BasicInteractionRoute:_viewTemplates_key": "text/html",
"BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/register.html.ejs"
},
"responseTemplates": {
"BasicInteractionRoute:_responseTemplates_key": "text/html",
"BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/register-response.html.ejs"
},
"controls": {
"BasicInteractionRoute:_controls_key": "register",
"BasicInteractionRoute:_controls_value": "/register/"
},
"handler": {
"@type": "RelativeInteractionRoute",
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
"relativePath": "/idp/register/",
"source": {
"@type": "RegistrationHandler",
"registrationManager": {
"@type": "RegistrationManager",

View File

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

View File

@ -12,10 +12,15 @@ import type { AnyObject,
ErrorOut,
Adapter } from 'oidc-provider';
import { Provider } from 'oidc-provider';
import type { Operation } from '../../http/Operation';
import type { ErrorHandler } from '../../http/output/error/ErrorHandler';
import type { ResponseWriter } from '../../http/output/ResponseWriter';
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage';
import { ensureTrailingSlash, joinUrl } from '../../util/PathUtil';
import { InternalServerError } from '../../util/errors/InternalServerError';
import { RedirectHttpError } from '../../util/errors/RedirectHttpError';
import { joinUrl } from '../../util/PathUtil';
import type { InteractionHandler } from '../interaction/InteractionHandler';
import type { AdapterFactory } from '../storage/AdapterFactory';
import type { ProviderFactory } from './ProviderFactory';
@ -33,10 +38,9 @@ export interface IdentityProviderFactoryArgs {
*/
oidcPath: string;
/**
* The entry point for the custom IDP handlers of the server.
* Should start with a slash.
* The handler responsible for redirecting interaction requests to the correct URL.
*/
idpPath: string;
interactionHandler: InteractionHandler;
/**
* Storage used to store cookie and JWT keys so they can be re-used in case of multithreading.
*/
@ -59,14 +63,14 @@ const COOKIES_KEY = 'cookie-secret';
* The provider will be cached and returned on subsequent calls.
* Cookie and JWT keys will be stored in an internal storage so they can be re-used over multiple threads.
* Necessary claims for Solid OIDC interactions will be added.
* Routes will be updated based on the `baseUrl` and `idpPath`.
* Routes will be updated based on the `baseUrl` and `oidcPath`.
*/
export class IdentityProviderFactory implements ProviderFactory {
private readonly config: Configuration;
private readonly adapterFactory!: AdapterFactory;
private readonly baseUrl!: string;
private readonly oidcPath!: string;
private readonly idpPath!: string;
private readonly interactionHandler!: InteractionHandler;
private readonly storage!: KeyValueStorage<string, unknown>;
private readonly errorHandler!: ErrorHandler;
private readonly responseWriter!: ResponseWriter;
@ -78,9 +82,6 @@ export class IdentityProviderFactory implements ProviderFactory {
* @param args - Remaining parameters required for the factory.
*/
public constructor(config: Configuration, args: IdentityProviderFactoryArgs) {
if (!args.idpPath.startsWith('/')) {
throw new Error('idpPath needs to start with a /');
}
this.config = config;
Object.assign(this, args);
}
@ -230,7 +231,26 @@ export class IdentityProviderFactory implements ProviderFactory {
// (missing user session, requested ACR not fulfilled, prompt requested, ...)
// it will resolve the interactions.url helper function and redirect the User-Agent to that url.
config.interactions = {
url: (): string => ensureTrailingSlash(this.idpPath),
url: async(ctx, oidcInteraction): Promise<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 = {
@ -254,7 +274,7 @@ export class IdentityProviderFactory implements ProviderFactory {
*/
private configureErrors(config: Configuration): 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;
const result = await this.errorHandler.handleSafe({ error, preferences: { type: { 'text/plain': 1 }}});
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 { FoundHttpError } from '../../util/errors/FoundHttpError';
import { BaseInteractionHandler } from './BaseInteractionHandler';
import type { InteractionHandlerInput } from './InteractionHandler';
import { InteractionHandler } from './InteractionHandler';
import type { InteractionCompleterInput, InteractionCompleter } from './util/InteractionCompleter';
/**
* Abstract class for {@link InteractionHandler}s that need to call an {@link InteractionCompleter}.
* Abstract extension of {@link BaseInteractionHandler} for handlers that need to call an {@link InteractionCompleter}.
* This is required by handlers that handle IDP behaviour
* and need to complete an OIDC interaction by redirecting back to the client,
* such as when logging in.
@ -13,17 +13,17 @@ import type { InteractionCompleterInput, InteractionCompleter } from './util/Int
* Calls the InteractionCompleter with the results returned by the helper function
* and throw a corresponding {@link FoundHttpError}.
*/
export abstract class CompletingInteractionHandler extends InteractionHandler {
export abstract class CompletingInteractionHandler extends BaseInteractionHandler {
protected readonly interactionCompleter: InteractionCompleter;
protected constructor(interactionCompleter: InteractionCompleter) {
super();
protected constructor(view: Record<string, unknown>, interactionCompleter: InteractionCompleter) {
super(view);
this.interactionCompleter = interactionCompleter;
}
public async canHandle(input: InteractionHandlerInput): Promise<void> {
await super.canHandle(input);
if (!input.oidcInteraction) {
if (input.operation.method === 'POST' && !input.oidcInteraction) {
throw new BadRequestHttpError(
'This action can only be performed as part of an OIDC authentication flow.',
{ errorCode: 'E0002' },
@ -31,7 +31,7 @@ export abstract class CompletingInteractionHandler extends InteractionHandler {
}
}
public async handle(input: InteractionHandlerInput): Promise<never> {
public async handlePost(input: InteractionHandlerInput): Promise<never> {
// Interaction is defined due to canHandle call
const parameters = await this.getCompletionParameters(input as Required<InteractionHandlerInput>);
const location = await this.interactionCompleter.handleSafe(parameters);
@ -40,6 +40,7 @@ export abstract class CompletingInteractionHandler extends InteractionHandler {
/**
* Generates the parameters necessary to call an InteractionCompleter.
* The input parameters are the same that the `handlePost` function was called with.
* @param input - The original input parameters to the `handle` function.
*/
protected abstract getCompletionParameters(input: Required<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';
/**
* Simple InteractionHttpHandler that sends the session accountId to the InteractionCompleter as webId.
* Simple CompletingInteractionRoute that returns the session accountId as webId.
* This is relevant when a client already logged in this session and tries logging in again.
*/
export class SessionHttpHandler extends CompletingInteractionHandler {
export class ExistingLoginHandler extends CompletingInteractionHandler {
public constructor(interactionCompleter: InteractionCompleter) {
super(interactionCompleter);
super({}, interactionCompleter);
}
protected async getCompletionParameters({ operation, oidcInteraction }: Required<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 { Operation } from '../../http/Operation';
import type { Representation } from '../../http/representation/Representation';
import { APPLICATION_JSON } from '../../util/ContentTypes';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
@ -9,7 +10,7 @@ export type Interaction = NonNullable<KoaContextWithOIDC['oidc']['entities']['In
export interface InteractionHandlerInput {
/**
* The operation to execute
* The operation to execute.
*/
operation: Operation;
/**
@ -19,25 +20,14 @@ export interface InteractionHandlerInput {
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.
* 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> {
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.');
}
}

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

View File

@ -6,9 +6,22 @@ import { readJsonStream } from '../../../../util/StreamUtil';
import { CompletingInteractionHandler } from '../../CompletingInteractionHandler';
import type { InteractionHandlerInput } from '../../InteractionHandler';
import type { InteractionCompleterInput, InteractionCompleter } from '../../util/InteractionCompleter';
import type { AccountStore } from '../storage/AccountStore';
const loginView = {
required: {
email: 'string',
password: 'string',
remember: 'boolean',
},
} as const;
interface LoginInput {
email: string;
password: string;
remember: boolean;
}
/**
* Handles the submission of the Login Form and logs the user in.
* Will throw a RedirectHttpError on success.
@ -19,12 +32,13 @@ export class LoginHandler extends CompletingInteractionHandler {
private readonly accountStore: AccountStore;
public constructor(accountStore: AccountStore, interactionCompleter: InteractionCompleter) {
super(interactionCompleter);
super(loginView, interactionCompleter);
this.accountStore = accountStore;
}
protected async getCompletionParameters({ operation, oidcInteraction }: Required<InteractionHandlerInput>):
protected async getCompletionParameters(input: Required<InteractionHandlerInput>):
Promise<InteractionCompleterInput> {
const { operation, oidcInteraction } = input;
const { email, password, remember } = await this.parseInput(operation);
// Try to log in, will error if email/password combination is invalid
const webId = await this.accountStore.authenticate(email, password);
@ -39,15 +53,12 @@ export class LoginHandler extends CompletingInteractionHandler {
}
/**
* Parses and validates the input form data.
* Validates the input data. Also makes sure remember is a boolean.
* Will throw an error in case something is wrong.
* All relevant data that was correct up to that point will be prefilled.
*/
private async parseInput(operation: Operation): Promise<{ email: string; password: string; remember: boolean }> {
const prefilled: Record<string, string> = {};
private async parseInput(operation: Operation): Promise<LoginInput> {
const { email, password, remember } = await readJsonStream(operation.body.data);
assert(typeof email === 'string' && email.length > 0, 'Email required');
prefilled.email = email;
assert(typeof password === 'string' && password.length > 0, 'Password required');
return { email, password, remember: Boolean(remember) };
}

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 { APPLICATION_JSON } from '../../../../util/ContentTypes';
import { readJsonStream } from '../../../../util/StreamUtil';
import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler';
import { InteractionHandler } from '../../InteractionHandler';
import type { RegistrationManager, RegistrationResponse } from '../util/RegistrationManager';
import { BaseInteractionHandler } from '../../BaseInteractionHandler';
import type { InteractionHandlerInput } from '../../InteractionHandler';
import type { RegistrationManager } from '../util/RegistrationManager';
const registrationView = {
required: {
email: 'string',
password: 'string',
confirmPassword: 'string',
createWebId: 'boolean',
register: 'boolean',
createPod: 'boolean',
rootPod: 'boolean',
},
optional: {
webId: 'string',
podName: 'string',
template: 'string',
},
} as const;
/**
* Supports registration based on the `RegistrationManager` behaviour.
*/
export class RegistrationHandler extends InteractionHandler {
export class RegistrationHandler extends BaseInteractionHandler {
protected readonly logger = getLoggerFor(this);
private readonly registrationManager: RegistrationManager;
public constructor(registrationManager: RegistrationManager) {
super();
super(registrationView);
this.registrationManager = registrationManager;
}
public async handle({ operation }: InteractionHandlerInput):
Promise<InteractionResponseResult<RegistrationResponse>> {
public async handlePost({ operation }: InteractionHandlerInput): Promise<Representation> {
const data = await readJsonStream(operation.body.data);
const validated = this.registrationManager.validateInput(data, false);
const details = await this.registrationManager.register(validated, false);
return { type: 'response', details };
return new BasicRepresentation(JSON.stringify(details), operation.target, APPLICATION_JSON);
}
}

View File

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

View File

@ -1,101 +1,43 @@
import type { Operation } from '../../../http/Operation';
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
import { createErrorMessage, isError } from '../../../util/errors/ErrorUtil';
import { InternalServerError } from '../../../util/errors/InternalServerError';
import { RedirectHttpError } from '../../../util/errors/RedirectHttpError';
import { trimTrailingSlashes } from '../../../util/PathUtil';
import type {
InteractionHandler,
Interaction,
} from '../InteractionHandler';
import type { InteractionRoute, TemplatedInteractionResult } from './InteractionRoute';
import type { Representation } from '../../../http/representation/Representation';
import { NotFoundHttpError } from '../../../util/errors/NotFoundHttpError';
import { UnsupportedAsyncHandler } from '../../../util/handlers/UnsupportedAsyncHandler';
import { InteractionHandler } from '../InteractionHandler';
import type { InteractionHandlerInput } from '../InteractionHandler';
import type { InteractionRoute } from './InteractionRoute';
/**
* Default implementation of the InteractionRoute.
* See function comments for specifics.
*/
export class BasicInteractionRoute implements InteractionRoute {
public readonly route: RegExp;
public readonly handler: InteractionHandler;
public readonly viewTemplates: Record<string, string>;
public readonly prompt?: string;
public readonly responseTemplates: Record<string, string>;
public readonly controls: Record<string, string>;
/**
* @param route - Regex to match this route.
* @param viewTemplates - Templates to render on GET requests.
* Keys are content-types, values paths to a template.
* @param handler - Handler to call on POST requests.
* @param prompt - In case of requests to the IDP entry point, the session prompt will be compared to this.
* @param responseTemplates - Templates to render as a response to POST requests when required.
* Keys are content-types, values paths to a template.
* @param controls - Controls to add to the response JSON.
* The keys will be copied and the values will be converted to full URLs.
*/
public constructor(route: string,
viewTemplates: Record<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;
}
/**
* Returns the stored controls.
*/
public getControls(): Record<string, string> {
return this.controls;
}
/**
* Checks support by comparing the prompt if the path targets the base URL,
* and otherwise comparing with the stored route regular expression.
*/
public supportsPath(path: string, prompt?: string): boolean {
// In case the request targets the IDP entry point the prompt determines where to go
if (trimTrailingSlashes(path).length === 0 && prompt) {
return this.prompt === prompt;
}
return this.route.test(path);
}
/**
* GET requests return a default response result.
* POST requests return the InteractionHandler result.
* InteractionHandler errors will be converted into response results.
* Default implementation of an InteractionHandler with an InteractionRoute.
*
* All results will be appended with the matching template paths.
* Rejects operations that target a different path,
* otherwise the input parameters get passed to the source handler.
*
* Will error for other methods
* 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.
*/
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;
export class BasicInteractionRoute extends InteractionHandler implements InteractionRoute {
private readonly path: string;
private readonly source: InteractionHandler;
public constructor(path: string, source?: InteractionHandler) {
super();
this.path = path;
this.source = source ?? new UnsupportedAsyncHandler('This route has no associated handler.');
}
const error = isError(err) ? err : new InternalServerError(createErrorMessage(err));
// Potentially render the error in the view
return { type: 'error', error, templateFiles: this.viewTemplates };
public getPath(): string {
return this.path;
}
default:
throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`);
public async canHandle(input: InteractionHandlerInput): Promise<void> {
const { target } = input.operation;
const path = this.getPath();
if (target.path !== path) {
throw new NotFoundHttpError();
}
await this.source.canHandle(input);
}
public async handle(input: InteractionHandlerInput): Promise<Representation> {
return this.source.handle(input);
}
}

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 {
/**
* Returns the control fields that should be added to response objects.
* Keys are control names, values are relative URL paths.
* @returns The absolute path of this route.
*/
getControls: () => Record<string, 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>;
getPath: () => string;
}

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

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

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

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 { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import { CompletingInteractionHandler } from '../../../../src/identity/interaction/CompletingInteractionHandler';
import type { Interaction, InteractionHandlerInput } from '../../../../src/identity/interaction/InteractionHandler';
import type {
Interaction,
InteractionHandlerInput,
} from '../../../../src/identity/interaction/InteractionHandler';
import type {
InteractionCompleter,
InteractionCompleterInput,
@ -11,7 +14,7 @@ import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplemen
const webId = 'http://alice.test.com/card#me';
class DummyCompletingInteractionHandler extends CompletingInteractionHandler {
public constructor(interactionCompleter: InteractionCompleter) {
super(interactionCompleter);
super({}, interactionCompleter);
}
public async getCompletionParameters(input: Required<InteractionHandlerInput>): Promise<InteractionCompleterInput> {
@ -28,7 +31,10 @@ describe('A CompletingInteractionHandler', (): void => {
beforeEach(async(): Promise<void> => {
const representation = new BasicRepresentation('', 'application/json');
operation = { body: representation } as any;
operation = {
method: 'POST',
body: representation,
} as any;
interactionCompleter = {
handleSafe: jest.fn().mockResolvedValue(location),
@ -39,10 +45,15 @@ describe('A CompletingInteractionHandler', (): void => {
it('calls the parent JSON canHandle check.', async(): Promise<void> => {
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({
statusCode: 400,
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 { SessionHttpHandler } from '../../../../src/identity/interaction/SessionHttpHandler';
import { ExistingLoginHandler } from '../../../../src/identity/interaction/ExistingLoginHandler';
import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler';
import type {
InteractionCompleter,
InteractionCompleterInput,
} from '../../../../src/identity/interaction/util/InteractionCompleter';
import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { createPostJsonOperation } from './email-password/handler/Util';
class PublicSessionHttpHandler extends SessionHttpHandler {
public constructor(interactionCompleter: InteractionCompleter) {
super(interactionCompleter);
}
public async getCompletionParameters(input: Required<InteractionHandlerInput>): Promise<InteractionCompleterInput> {
return super.getCompletionParameters(input);
}
}
describe('A SessionHttpHandler', (): void => {
describe('An ExistingLoginHandler', (): void => {
const webId = 'http://test.com/id#me';
let oidcInteraction: Interaction;
let interactionCompleter: jest.Mocked<InteractionCompleter>;
let handler: PublicSessionHttpHandler;
let handler: ExistingLoginHandler;
beforeEach(async(): Promise<void> => {
oidcInteraction = { session: { accountId: webId }} as any;
@ -30,18 +20,19 @@ describe('A SessionHttpHandler', (): void => {
handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'),
} as any;
handler = new PublicSessionHttpHandler(interactionCompleter);
handler = new ExistingLoginHandler(interactionCompleter);
});
it('requires an oidcInteraction with a defined session.', async(): Promise<void> => {
oidcInteraction.session = undefined;
await expect(handler.getCompletionParameters({ operation: {} as any, oidcInteraction }))
await expect(handler.handle({ operation: createPostJsonOperation({}), oidcInteraction }))
.rejects.toThrow(NotImplementedHttpError);
});
it('returns the correct completion parameters.', async(): Promise<void> => {
const operation = createPostJsonOperation({ remember: true });
await expect(handler.getCompletionParameters({ operation, oidcInteraction }))
.resolves.toEqual({ oidcInteraction, webId, shouldRemember: true });
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1);
expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ oidcInteraction, webId, shouldRemember: true });
});
});

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 type {
InteractionResponseResult,
} from '../../../../src/identity/interaction/InteractionHandler';
import type { Representation } from '../../../../src/http/representation/Representation';
import {
InteractionHandler,
} from '../../../../src/identity/interaction/InteractionHandler';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
class SimpleInteractionHandler extends InteractionHandler {
public async handle(): Promise<InteractionResponseResult> {
return { type: 'response' };
public async handle(): Promise<Representation> {
return new BasicRepresentation();
}
}
describe('An InteractionHandler', (): void => {
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');
await expect(handler.canHandle({ operation: { body: representation }} as any)).resolves.toBeUndefined();
@ -24,6 +22,7 @@ describe('An InteractionHandler', (): void => {
await expect(handler.canHandle({ operation: { body: representation }} as any))
.rejects.toThrow(NotImplementedHttpError);
await expect(handler.canHandle({ operation: {}} as any)).rejects.toThrow(NotImplementedHttpError);
representation = new BasicRepresentation();
await expect(handler.canHandle({ operation: { body: representation }} as any)).resolves.toBeUndefined();
});
});

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import {
ResetPasswordHandler,
} from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import { readJsonStream } from '../../../../../../src/util/StreamUtil';
import { createPostJsonOperation } from './Util';
describe('A ResetPasswordHandler', (): void => {
@ -46,7 +47,9 @@ describe('A ResetPasswordHandler', (): void => {
it('renders a message on success.', async(): Promise<void> => {
operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!', recordId }, url);
await expect(handler.handle({ operation })).resolves.toEqual({ type: 'response' });
const result = await handler.handle({ operation });
await expect(readJsonStream(result.data)).resolves.toEqual({});
expect(result.metadata.contentType).toBe('application/json');
expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1);
expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId);
expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1);

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

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/');
});
});