feat: Moved IDP response and template behaviour to single class

This commit is contained in:
Joachim Van Herwegen 2021-07-29 16:31:37 +02:00
parent 2a82c4f06e
commit 9d337ba80c
42 changed files with 662 additions and 734 deletions

View File

@ -0,0 +1,15 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@graph": [
{
"comment": "The storage adapter that persists usernames, passwords, etc.",
"@id": "urn:solid-server:auth:password:AccountStore",
"@type": "BaseAccountStore",
"args_storageName": "/idp/email-password-db",
"args_saltRounds": 10,
"args_storage": {
"@id": "urn:solid-server:default:IdpStorage"
}
}
]
}

View File

@ -1,8 +1,9 @@
{ {
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"import": [ "import": [
"files-scs:config/identity/handler/account-store/default.json",
"files-scs:config/identity/handler/adapter-factory/webid.json", "files-scs:config/identity/handler/adapter-factory/webid.json",
"files-scs:config/identity/handler/interaction/handler.json", "files-scs:config/identity/handler/interaction/routes.json",
"files-scs:config/identity/handler/key-value/storage.json", "files-scs:config/identity/handler/key-value/storage.json",
"files-scs:config/identity/handler/provider-factory/identity.json" "files-scs:config/identity/handler/provider-factory/identity.json"
], ],
@ -18,8 +19,17 @@
{ {
"@id": "urn:solid-server:default:IdentityProviderHttpHandler", "@id": "urn:solid-server:default:IdentityProviderHttpHandler",
"@type": "IdentityProviderHttpHandler", "@type": "IdentityProviderHttpHandler",
"idpPath": "/idp",
"providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }, "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" },
"interactionHttpHandler": { "@id": "urn:solid-server:auth:password:InteractionHttpHandler" }, "templateHandler": {
"@type": "TemplateHandler",
"templateEngine": { "@type": "EjsTemplateEngine" }
},
"interactionCompleter": {
"comment": "Responsible for finishing OIDC interactions.",
"@type": "InteractionCompleter",
"providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }
},
"errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" }, "errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" },
"responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" } "responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }
} }

View File

@ -1,42 +0,0 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"import": [
"files-scs:config/identity/handler/interaction/handlers/forgot-password.json",
"files-scs:config/identity/handler/interaction/handlers/initial.json",
"files-scs:config/identity/handler/interaction/handlers/login.json",
"files-scs:config/identity/handler/interaction/handlers/reset-password.json",
"files-scs:config/identity/handler/interaction/handlers/session.json"
],
"@graph": [
{
"comment": "Http handler to take care of all routing on for the email password interaction",
"@id": "urn:solid-server:auth:password:InteractionHttpHandler",
"@type": "WaterfallHandler",
"handlers": [
{ "@id": "urn:solid-server:auth:password:InitialInteractionHandler" },
{ "@id": "urn:solid-server:auth:password:LoginInteractionHandler" },
{ "@id": "urn:solid-server:auth:password:SessionInteractionHandler" },
{ "@id": "urn:solid-server:auth:password:ForgotPasswordInteractionHandler" },
{ "@id": "urn:solid-server:auth:password:ResetPasswordInteractionHandler" }
]
},
{
"comment": "Below are extra classes used by the handlers."
},
{
"comment": "The storage adapter that persists usernames, passwords, etc.",
"@id": "urn:solid-server:auth:password:AccountStore",
"@type": "BaseAccountStore",
"args_storageName": "/idp/email-password-db",
"args_saltRounds": 10,
"args_storage": { "@id": "urn:solid-server:default:IdpStorage" }
},
{
"comment": "Responsible for completing an OIDC interaction after login or registration",
"@id": "urn:solid-server:auth:password:InteractionCompleter",
"@type": "InteractionCompleter"
}
]
}

View File

@ -1,43 +0,0 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles all functionality on the forgot password page",
"@id": "urn:solid-server:auth:password:ForgotPasswordInteractionHandler",
"@type": "IdpRouteController",
"pathName": "^/idp/forgotpassword/?$",
"postHandler": {
"@type": "ForgotPasswordHandler",
"args_messageRenderHandler": { "@id": "urn:solid-server:auth:password:EmailSentRenderHandler" },
"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": "$PACKAGE_ROOT/templates/identity/email-password/reset-password-email.html.ejs"
},
"args_emailSender": { "@id": "urn:solid-server:default:EmailSender" }
},
"renderHandler": { "@id": "urn:solid-server:auth:password:ForgotPasswordRenderHandler" }
},
{
"comment": "Renders the Email Sent message page",
"@id": "urn:solid-server:auth:password:EmailSentRenderHandler",
"@type": "TemplateHandler",
"templateEngine": {
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/identity/email-password/email-sent.html.ejs"
}
},
{
"comment": "Renders the forgot password page",
"@id": "urn:solid-server:auth:password:ForgotPasswordRenderHandler",
"@type": "TemplateHandler",
"templateEngine": {
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/identity/email-password/forgot-password.html.ejs"
}
}
]
}

View File

@ -1,23 +0,0 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles the initial route when the user is directed from their app to the IdP",
"@id": "urn:solid-server:auth:password:InitialInteractionHandler",
"@type": "RouterHandler",
"allowedMethods": [ "GET" ],
"allowedPathNames": [ "^/idp/?$" ],
"handler": {
"@type": "InitialInteractionHandler",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"redirectMap": [
{
"InitialInteractionHandler:_redirectMap_key": "consent",
"InitialInteractionHandler:_redirectMap_value": "/idp/confirm"
}
],
"redirectMap_default": "/idp/login"
}
}
]
}

View File

@ -1,27 +0,0 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles all functionality on the Login Page",
"@id": "urn:solid-server:auth:password:LoginInteractionHandler",
"@type": "IdpRouteController",
"pathName": "^/idp/login/?$",
"postHandler": {
"@type": "LoginHandler",
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
"args_interactionCompleter": { "@id": "urn:solid-server:auth:password:InteractionCompleter" }
},
"renderHandler": { "@id": "urn:solid-server:auth:password:LoginRenderHandler" }
},
{
"comment": "Renders the login page",
"@id": "urn:solid-server:auth:password:LoginRenderHandler",
"@type": "TemplateHandler",
"templateEngine": {
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/identity/email-password/login.html.ejs"
}
}
]
}

View File

@ -1,37 +0,0 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.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",
"@id": "urn:solid-server:auth:password:ResetPasswordInteractionHandler",
"@type": "IdpRouteController",
"pathName": "^/idp/resetpassword/[^/]+$",
"postHandler": {
"@type": "ResetPasswordHandler",
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
"args_messageRenderHandler": { "@id": "urn:solid-server:auth:password:MessageRenderHandler" }
},
"renderHandler": { "@id": "urn:solid-server:auth:password:ResetPasswordRenderHandler" }
},
{
"comment": "Renders the reset password page",
"@id": "urn:solid-server:auth:password:ResetPasswordRenderHandler",
"@type": "TemplateHandler",
"templateEngine": {
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/identity/email-password/reset-password.html.ejs"
}
},
{
"comment": "Renders a generic page that says a message",
"@id": "urn:solid-server:auth:password:MessageRenderHandler",
"@type": "TemplateHandler",
"templateEngine": {
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/identity/email-password/message.html.ejs"
}
}
]
}

View File

@ -1,26 +0,0 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles confirm requests",
"@id": "urn:solid-server:auth:password:SessionInteractionHandler",
"@type": "IdpRouteController",
"pathName": "^/idp/confirm/?$",
"postHandler": {
"@type": "SessionHttpHandler",
"interactionCompleter": { "@id": "urn:solid-server:auth:password:InteractionCompleter" }
},
"renderHandler": { "@id": "urn:solid-server:auth:password:ConfirmRenderHandler" }
},
{
"comment": "Renders the confirmation page",
"@id": "urn:solid-server:auth:password:ConfirmRenderHandler",
"@type": "TemplateHandler",
"templateEngine": {
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/identity/email-password/confirm.html.ejs"
}
}
]
}

View File

@ -0,0 +1,20 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"import": [
"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/reset-password.json",
"files-scs:config/identity/handler/interaction/routes/session.json"
],
"@graph": [
{
"@id": "urn:solid-server:default:IdentityProviderHttpHandler",
"IdentityProviderHttpHandler:_interactionRoutes": [
{ "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" },
{ "@id": "urn:solid-server:auth:password:LoginRoute" },
{ "@id": "urn:solid-server:auth:password:ResetPasswordRoute" },
{ "@id": "urn:solid-server:auth:password:SessionRoute" }
]
}
]
}

View File

@ -0,0 +1,24 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles all functionality on the forgot password page",
"@id": "urn:solid-server:auth:password:ForgotPasswordRoute",
"@type": "InteractionRoute",
"route": "^/forgotpassword/?$",
"viewTemplate": "$PACKAGE_ROOT/templates/identity/email-password/forgot-password.html.ejs",
"responseTemplate": "$PACKAGE_ROOT/templates/identity/email-password/email-sent.html.ejs",
"handler": {
"@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": "$PACKAGE_ROOT/templates/identity/email-password/reset-password-email.html.ejs"
},
"args_emailSender": { "@id": "urn:solid-server:default:EmailSender" }
}
}
]
}

View File

@ -0,0 +1,17 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles all functionality on the Login Page",
"@id": "urn:solid-server:auth:password:LoginRoute",
"@type": "InteractionRoute",
"route": "^/login/?$",
"prompt": "default",
"viewTemplate": "$PACKAGE_ROOT/templates/identity/email-password/login.html.ejs",
"handler": {
"@type": "LoginHandler",
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }
}
}
]
}

View File

@ -0,0 +1,18 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.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",
"@id": "urn:solid-server:auth:password:ResetPasswordRoute",
"@type": "InteractionRoute",
"route": "^/resetpassword(/[^/]*)?$",
"viewTemplate": "$PACKAGE_ROOT/templates/identity/email-password/reset-password.html.ejs",
"responseTemplate": "$PACKAGE_ROOT/templates/identity/email-password/message.html.ejs",
"handler": {
"@type": "ResetPasswordHandler",
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }
}
}
]
}

View File

@ -0,0 +1,17 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles confirm requests",
"@id": "urn:solid-server:auth:password:SessionRoute",
"@type": "InteractionRoute",
"route": "^/confirm/?$",
"prompt": "consent",
"viewTemplate": "$PACKAGE_ROOT/templates/identity/email-password/confirm.html.ejs",
"handler": {
"@type": "SessionHttpHandler",
"providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }
}
}
]
}

View File

@ -1,14 +1,14 @@
{ {
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"import": [ "import": [
"files-scs:config/identity/registration/handler/registration.json" "files-scs:config/identity/registration/route/registration.json"
], ],
"@graph": [ "@graph": [
{ {
"comment": "Enable registration by adding a registration handler to the list of interaction handlers.", "comment": "Enable registration by adding a registration handler to the list of interaction routes.",
"@id": "urn:solid-server:auth:password:InteractionHttpHandler", "@id": "urn:solid-server:default:IdentityProviderHttpHandler",
"WaterfallHandler:_handlers": [ "IdentityProviderHttpHandler:_interactionRoutes": [
{ "@id": "urn:solid-server:auth:password:RegistrationInteractionHandler" } { "@id": "urn:solid-server:auth:password:RegistrationRoute" }
] ]
} }
] ]

View File

@ -1,41 +0,0 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles all functionality on the register page",
"@id": "urn:solid-server:auth:password:RegistrationInteractionHandler",
"@type": "IdpRouteController",
"pathName": "^/idp/register/?$",
"postHandler": {
"@type": "RegistrationHandler",
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"args_webIdSuffix": "/profile/card#me",
"args_identifierGenerator": { "@id": "urn:solid-server:default:IdentifierGenerator" },
"args_ownershipValidator": { "@id": "urn:solid-server:auth:password:OwnershipValidator" },
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
"args_podManager": { "@id": "urn:solid-server:default:PodManager" },
"args_responseHandler": { "@id": "urn:solid-server:auth:password:RegisterResponseRenderHandler" }
},
"renderHandler": { "@id": "urn:solid-server:auth:password:RegisterRenderHandler" }
},
{
"comment": "Renders the register page",
"@id": "urn:solid-server:auth:password:RegisterRenderHandler",
"@type": "TemplateHandler",
"templateEngine": {
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/identity/email-password/register.html.ejs"
}
},
{
"comment": "Renders the successful registration page",
"@id": "urn:solid-server:auth:password:RegisterResponseRenderHandler",
"@type": "TemplateHandler",
"templateEngine": {
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/identity/email-password/register-response.html.ejs"
}
}
]
}

View File

@ -0,0 +1,22 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles all functionality on the register page",
"@id": "urn:solid-server:auth:password:RegistrationRoute",
"@type": "InteractionRoute",
"route": "^/register/?$",
"viewTemplate": "$PACKAGE_ROOT/templates/identity/email-password/register.html.ejs",
"responseTemplate": "$PACKAGE_ROOT/templates/identity/email-password/register-response.html.ejs",
"handler": {
"@type": "RegistrationHandler",
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"args_webIdSuffix": "/profile/card#me",
"args_identifierGenerator": { "@id": "urn:solid-server:default:IdentifierGenerator" },
"args_ownershipValidator": { "@id": "urn:solid-server:auth:password:OwnershipValidator" },
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
"args_podManager": { "@id": "urn:solid-server:default:PodManager" }
}
}
]
}

View File

@ -3,16 +3,57 @@ import type { ResponseWriter } from '../ldp/http/ResponseWriter';
import { getLoggerFor } from '../logging/LogUtil'; import { getLoggerFor } from '../logging/LogUtil';
import type { HttpHandlerInput } from '../server/HttpHandler'; import type { HttpHandlerInput } from '../server/HttpHandler';
import { HttpHandler } from '../server/HttpHandler'; import { HttpHandler } from '../server/HttpHandler';
import { assertError } from '../util/errors/ErrorUtil'; import type { HttpRequest } from '../server/HttpRequest';
import type { HttpResponse } from '../server/HttpResponse';
import type { TemplateHandler } from '../server/util/TemplateHandler';
import { BadRequestHttpError } from '../util/errors/BadRequestHttpError';
import { assertError, createErrorMessage } from '../util/errors/ErrorUtil';
import { InternalServerError } from '../util/errors/InternalServerError';
import { trimTrailingSlashes } from '../util/PathUtil';
import type { ProviderFactory } from './configuration/ProviderFactory'; import type { ProviderFactory } from './configuration/ProviderFactory';
import type { InteractionHttpHandler } from './interaction/InteractionHttpHandler'; import type { InteractionHandler,
InteractionHandlerResult } from './interaction/email-password/handler/InteractionHandler';
import { IdpInteractionError } from './interaction/util/IdpInteractionError';
import type { InteractionCompleter } from './interaction/util/InteractionCompleter';
/**
* All the information that is required to handle a request to a custom IDP path.
*/
export class InteractionRoute {
public readonly route: RegExp;
public readonly handler: InteractionHandler;
public readonly viewTemplate: string;
public readonly prompt?: string;
public readonly responseTemplate?: string;
/**
* @param route - Regex to match this route.
* @param viewTemplate - Template to render on GET requests.
* @param handler - Handler to call on POST requests.
* @param prompt - In case of requests to the IDP entry point, the session prompt will be compared to this.
* One entry should have a value of "default" here in case there are no prompt matches.
* @param responseTemplate - Template to render as a response to POST requests when required.
*/
public constructor(route: string,
viewTemplate: string,
handler: InteractionHandler,
prompt?: string,
responseTemplate?: string) {
this.route = new RegExp(route, 'u');
this.viewTemplate = viewTemplate;
this.handler = handler;
this.prompt = prompt;
this.responseTemplate = responseTemplate;
}
}
/** /**
* Handles all requests relevant for the entire IDP interaction, * Handles all requests relevant for the entire IDP interaction,
* by sending them to either the stored {@link InteractionHttpHandler}, * by sending them to either a matching {@link InteractionRoute},
* or the generated Provider from the {@link ProviderFactory} if the first does not support the request. * or the generated Provider from the {@link ProviderFactory} if there is no match.
* *
* The InteractionHttpHandler would handle all requests where we need custom behaviour, * The InteractionRoutes handle all requests where we need custom behaviour,
* such as everything related to generating and validating an account. * such as everything related to generating and validating an account.
* The Provider handles all the default request such as the initial handshake. * The Provider handles all the default request such as the initial handshake.
* *
@ -22,42 +63,158 @@ import type { InteractionHttpHandler } from './interaction/InteractionHttpHandle
export class IdentityProviderHttpHandler extends HttpHandler { export class IdentityProviderHttpHandler extends HttpHandler {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
private readonly idpPath: string;
private readonly providerFactory: ProviderFactory; private readonly providerFactory: ProviderFactory;
private readonly interactionHttpHandler: InteractionHttpHandler; private readonly interactionRoutes: InteractionRoute[];
private readonly templateHandler: TemplateHandler;
private readonly interactionCompleter: InteractionCompleter;
private readonly errorHandler: ErrorHandler; private readonly errorHandler: ErrorHandler;
private readonly responseWriter: ResponseWriter; private readonly responseWriter: ResponseWriter;
/**
* @param idpPath - Relative path of the IDP entry point.
* @param providerFactory - Used to generate the OIDC provider.
* @param interactionRoutes - All routes handling the custom IDP behaviour.
* @param templateHandler - Used for rendering responses.
* @param interactionCompleter - Used for POST requests that need to be handled by the OIDC library.
* @param errorHandler - Converts errors to responses.
* @param responseWriter - Renders error responses.
*/
public constructor( public constructor(
idpPath: string,
providerFactory: ProviderFactory, providerFactory: ProviderFactory,
interactionHttpHandler: InteractionHttpHandler, interactionRoutes: InteractionRoute[],
templateHandler: TemplateHandler,
interactionCompleter: InteractionCompleter,
errorHandler: ErrorHandler, errorHandler: ErrorHandler,
responseWriter: ResponseWriter, responseWriter: ResponseWriter,
) { ) {
super(); super();
if (!idpPath.startsWith('/')) {
throw new Error('idpPath needs to start with a /');
}
// Trimming trailing slashes so the relative URL starts with a slash after slicing this off
this.idpPath = trimTrailingSlashes(idpPath);
this.providerFactory = providerFactory; this.providerFactory = providerFactory;
this.interactionHttpHandler = interactionHttpHandler; this.interactionRoutes = interactionRoutes;
this.templateHandler = templateHandler;
this.interactionCompleter = interactionCompleter;
this.errorHandler = errorHandler; this.errorHandler = errorHandler;
this.responseWriter = responseWriter; this.responseWriter = responseWriter;
} }
public async handle(input: HttpHandlerInput): Promise<void> { public async handle({ request, response }: HttpHandlerInput): Promise<void> {
const provider = await this.providerFactory.getProvider();
// If our own interaction handler does not support the input, it must be a request for the OIDC library
try { try {
await this.interactionHttpHandler.canHandle({ ...input, provider }); await this.handleRequest(request, response);
} catch {
this.logger.debug(`Sending request to oidc-provider: ${input.request.url}`);
return provider.callback(input.request, input.response);
}
try {
await this.interactionHttpHandler.handle({ ...input, provider });
} catch (error: unknown) { } catch (error: unknown) {
assertError(error); assertError(error);
// Setting preferences to text/plain since we didn't parse accept headers, see #764 // Setting preferences to text/plain since we didn't parse accept headers, see #764
const result = await this.errorHandler.handleSafe({ error, preferences: { type: { 'text/plain': 1 }}}); const result = await this.errorHandler.handleSafe({ error, preferences: { type: { 'text/plain': 1 }}});
await this.responseWriter.handleSafe({ response: input.response, result }); await this.responseWriter.handleSafe({ response, result });
} }
} }
/**
* Finds the matching route and resolves the request.
*/
private async handleRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
// If our own interaction handler does not support the input, it is either invalid or a request for the OIDC library
const route = await this.findRoute(request, response);
if (!route) {
const provider = await this.providerFactory.getProvider();
this.logger.debug(`Sending request to oidc-provider: ${request.url}`);
return provider.callback(request, response);
}
await this.resolveRoute(request, response, route);
}
/**
* Finds a route that supports the given request.
*/
private async findRoute(request: HttpRequest, response: HttpResponse): Promise<InteractionRoute | undefined> {
if (!request.url || !request.url.startsWith(this.idpPath)) {
// This is either an invalid request or a call to the .well-known configuration
return;
}
const url = request.url.slice(this.idpPath.length);
let route = this.getRouteMatch(url);
// In case the request targets the IDP entry point the prompt determines where to go
if (!route && (url === '/' || url === '')) {
const provider = await this.providerFactory.getProvider();
const interactionDetails = await provider.interactionDetails(request, response);
route = this.getPromptMatch(interactionDetails.prompt.name);
}
return route;
}
/**
* Handles the behaviour of an InteractionRoute.
* Will error if the route does not support the given request.
*
* GET requests go to the templateHandler, POST requests to the specific InteractionHandler of the route.
*/
private async resolveRoute(request: HttpRequest, response: HttpResponse, route: InteractionRoute): Promise<void> {
if (request.method === 'GET') {
// .ejs templates errors on undefined variables
return await this.handleTemplateResponse(response, route.viewTemplate, { errorMessage: '', prefilled: {}});
}
if (request.method === 'POST') {
let result: InteractionHandlerResult;
try {
result = await route.handler.handleSafe({ request, response });
} catch (error: unknown) {
// Render error in the view
const prefilled = IdpInteractionError.isInstance(error) ? error.prefilled : {};
const errorMessage = createErrorMessage(error);
return await this.handleTemplateResponse(response, route.viewTemplate, { errorMessage, prefilled });
}
if (result.type === 'complete') {
return await this.interactionCompleter.handleSafe({ ...result.details, request, response });
}
if (result.type === 'response' && route.responseTemplate) {
return await this.handleTemplateResponse(response, route.responseTemplate, result.details);
}
}
throw new BadRequestHttpError(`Unsupported request: ${request.method} ${request.url}`);
}
private async handleTemplateResponse(response: HttpResponse, templateFile: string, contents: NodeJS.Dict<any>):
Promise<void> {
await this.templateHandler.handleSafe({ response, templateFile, contents });
}
/**
* Find a route by matching the URL.
*/
private getRouteMatch(url: string): InteractionRoute | undefined {
for (const route of this.interactionRoutes) {
if (route.route.test(url)) {
return route;
}
}
}
/**
* Find a route by matching the prompt.
*/
private getPromptMatch(prompt: string): InteractionRoute {
let def: InteractionRoute | undefined;
for (const route of this.interactionRoutes) {
if (route.prompt === prompt) {
return route;
}
if (route.prompt === 'default') {
def = route;
}
}
if (!def) {
throw new InternalServerError('No handler for the default session prompt has been configured.');
}
return def;
}
} }

View File

@ -1,9 +0,0 @@
import type { Provider } from 'oidc-provider';
import type { HttpHandlerInput } from '../../server/HttpHandler';
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
export type InteractionHttpHandlerInput = HttpHandlerInput & {
provider: Provider;
};
export abstract class InteractionHttpHandler extends AsyncHandler<InteractionHttpHandlerInput> {}

View File

@ -1,24 +1,29 @@
import type { HttpHandlerInput } from '../../server/HttpHandler';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import type { InteractionHttpHandlerInput } from './InteractionHttpHandler'; import type { ProviderFactory } from '../configuration/ProviderFactory';
import { InteractionHttpHandler } from './InteractionHttpHandler'; import { InteractionHandler } from './email-password/handler/InteractionHandler';
import type { InteractionCompleter } from './util/InteractionCompleter'; import type { InteractionCompleteResult } from './email-password/handler/InteractionHandler';
/** /**
* Simple InteractionHttpHandler that sends the session accountId to the InteractionCompleter as webId. * Simple InteractionHttpHandler that sends the session accountId to the InteractionCompleter as webId.
*/ */
export class SessionHttpHandler extends InteractionHttpHandler { export class SessionHttpHandler extends InteractionHandler {
private readonly interactionCompleter: InteractionCompleter; private readonly providerFactory: ProviderFactory;
public constructor(interactionCompleter: InteractionCompleter) { public constructor(providerFactory: ProviderFactory) {
super(); super();
this.interactionCompleter = interactionCompleter; this.providerFactory = providerFactory;
} }
public async handle(input: InteractionHttpHandlerInput): Promise<void> { public async handle(input: HttpHandlerInput): Promise<InteractionCompleteResult> {
const details = await input.provider.interactionDetails(input.request, input.response); const provider = await this.providerFactory.getProvider();
const details = await provider.interactionDetails(input.request, input.response);
if (!details.session || !details.session.accountId) { if (!details.session || !details.session.accountId) {
throw new NotImplementedHttpError('Only confirm actions with a session and accountId are supported'); throw new NotImplementedHttpError('Only confirm actions with a session and accountId are supported');
} }
await this.interactionCompleter.handleSafe({ ...input, webId: details.session.accountId as any }); return {
type: 'complete',
details: { webId: details.session.accountId },
};
} }
} }

View File

@ -1,19 +1,17 @@
import assert from 'assert'; import assert from 'assert';
import urljoin from 'url-join'; import urljoin from 'url-join';
import { getLoggerFor } from '../../../../logging/LogUtil'; import { getLoggerFor } from '../../../../logging/LogUtil';
import type { HttpResponse } from '../../../../server/HttpResponse'; import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import { ensureTrailingSlash } from '../../../../util/PathUtil'; import { ensureTrailingSlash } from '../../../../util/PathUtil';
import type { TemplateEngine } from '../../../../util/templates/TemplateEngine'; import type { TemplateEngine } from '../../../../util/templates/TemplateEngine';
import type { InteractionHttpHandlerInput } from '../../InteractionHttpHandler';
import { InteractionHttpHandler } from '../../InteractionHttpHandler';
import type { EmailSender } from '../../util/EmailSender'; import type { EmailSender } from '../../util/EmailSender';
import { getFormDataRequestBody } from '../../util/FormDataUtil'; import { getFormDataRequestBody } from '../../util/FormDataUtil';
import type { IdpRenderHandler } from '../../util/IdpRenderHandler';
import { throwIdpInteractionError } from '../EmailPasswordUtil'; import { throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore'; import type { AccountStore } from '../storage/AccountStore';
import { InteractionHandler } from './InteractionHandler';
import type { InteractionResponseResult } from './InteractionHandler';
export interface ForgotPasswordHandlerArgs { export interface ForgotPasswordHandlerArgs {
messageRenderHandler: IdpRenderHandler;
accountStore: AccountStore; accountStore: AccountStore;
baseUrl: string; baseUrl: string;
idpPath: string; idpPath: string;
@ -24,10 +22,9 @@ export interface ForgotPasswordHandlerArgs {
/** /**
* Handles the submission of the ForgotPassword form * Handles the submission of the ForgotPassword form
*/ */
export class ForgotPasswordHandler extends InteractionHttpHandler { export class ForgotPasswordHandler extends InteractionHandler {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
private readonly messageRenderHandler: IdpRenderHandler;
private readonly accountStore: AccountStore; private readonly accountStore: AccountStore;
private readonly baseUrl: string; private readonly baseUrl: string;
private readonly idpPath: string; private readonly idpPath: string;
@ -36,7 +33,6 @@ export class ForgotPasswordHandler extends InteractionHttpHandler {
public constructor(args: ForgotPasswordHandlerArgs) { public constructor(args: ForgotPasswordHandlerArgs) {
super(); super();
this.messageRenderHandler = args.messageRenderHandler;
this.accountStore = args.accountStore; this.accountStore = args.accountStore;
this.baseUrl = ensureTrailingSlash(args.baseUrl); this.baseUrl = ensureTrailingSlash(args.baseUrl);
this.idpPath = args.idpPath; this.idpPath = args.idpPath;
@ -44,14 +40,14 @@ export class ForgotPasswordHandler extends InteractionHttpHandler {
this.emailSender = args.emailSender; this.emailSender = args.emailSender;
} }
public async handle(input: InteractionHttpHandlerInput): Promise<void> { public async handle(input: HttpHandlerInput): Promise<InteractionResponseResult<{ email: string }>> {
try { try {
// Validate incoming data // Validate incoming data
const { email } = await getFormDataRequestBody(input.request); const { email } = await getFormDataRequestBody(input.request);
assert(typeof email === 'string' && email.length > 0, 'Email required'); assert(typeof email === 'string' && email.length > 0, 'Email required');
await this.resetPassword(email); await this.resetPassword(email);
await this.sendResponse(input.response, email); return { type: 'response', details: { email }};
} catch (err: unknown) { } catch (err: unknown) {
throwIdpInteractionError(err, {}); throwIdpInteractionError(err, {});
} }
@ -88,22 +84,4 @@ export class ForgotPasswordHandler extends InteractionHttpHandler {
html: renderedEmail, html: renderedEmail,
}); });
} }
/**
* Sends a response through the messageRenderHandler.
* @param response - HttpResponse to send to.
* @param email - Will be inserted in `prefilled` for the template.
*/
private async sendResponse(response: HttpResponse, email: string): Promise<void> {
// Send response
await this.messageRenderHandler.handleSafe({
response,
contents: {
errorMessage: '',
prefilled: {
email,
},
},
});
}
} }

View File

@ -0,0 +1,20 @@
import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import { AsyncHandler } from '../../../../util/handlers/AsyncHandler';
import type { InteractionCompleterParams } from '../../util/InteractionCompleter';
export type InteractionHandlerResult = InteractionResponseResult | InteractionCompleteResult;
export interface InteractionResponseResult<T = NodeJS.Dict<any>> {
type: 'response';
details: T;
}
export interface InteractionCompleteResult {
type: 'complete';
details: InteractionCompleterParams;
}
/**
* Handler used for IDP interactions.
*/
export abstract class InteractionHandler extends AsyncHandler<HttpHandlerInput, InteractionHandlerResult> {}

View File

@ -1,40 +1,36 @@
import assert from 'assert'; import assert from 'assert';
import { getLoggerFor } from '../../../../logging/LogUtil'; import { getLoggerFor } from '../../../../logging/LogUtil';
import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import type { HttpRequest } from '../../../../server/HttpRequest'; import type { HttpRequest } from '../../../../server/HttpRequest';
import type { InteractionHttpHandlerInput } from '../../InteractionHttpHandler';
import { InteractionHttpHandler } from '../../InteractionHttpHandler';
import { getFormDataRequestBody } from '../../util/FormDataUtil'; import { getFormDataRequestBody } from '../../util/FormDataUtil';
import type { InteractionCompleter } from '../../util/InteractionCompleter';
import { throwIdpInteractionError } from '../EmailPasswordUtil'; import { throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore'; import type { AccountStore } from '../storage/AccountStore';
import { InteractionHandler } from './InteractionHandler';
export interface LoginHandlerArgs { import type { InteractionCompleteResult } from './InteractionHandler';
accountStore: AccountStore;
interactionCompleter: InteractionCompleter;
}
/** /**
* Handles the submission of the Login Form and logs the user in. * Handles the submission of the Login Form and logs the user in.
*/ */
export class LoginHandler extends InteractionHttpHandler { export class LoginHandler extends InteractionHandler {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
private readonly accountStore: AccountStore; private readonly accountStore: AccountStore;
private readonly interactionCompleter: InteractionCompleter;
public constructor(args: LoginHandlerArgs) { public constructor(accountStore: AccountStore) {
super(); super();
this.accountStore = args.accountStore; this.accountStore = accountStore;
this.interactionCompleter = args.interactionCompleter;
} }
public async handle(input: InteractionHttpHandlerInput): Promise<void> { public async handle(input: HttpHandlerInput): Promise<InteractionCompleteResult> {
const { email, password, remember } = await this.parseInput(input.request); const { email, password, remember } = await this.parseInput(input.request);
try { try {
// Try to log in, will error if email/password combination is invalid // Try to log in, will error if email/password combination is invalid
const webId = await this.accountStore.authenticate(email, password); const webId = await this.accountStore.authenticate(email, password);
await this.interactionCompleter.handleSafe({ ...input, webId, shouldRemember: Boolean(remember) });
this.logger.debug(`Logging in user ${email}`); this.logger.debug(`Logging in user ${email}`);
return {
type: 'complete',
details: { webId, shouldRemember: Boolean(remember) },
};
} catch (err: unknown) { } catch (err: unknown) {
throwIdpInteractionError(err, { email }); throwIdpInteractionError(err, { email });
} }

View File

@ -6,13 +6,13 @@ import type { IdentifierGenerator } from '../../../../pods/generate/IdentifierGe
import type { PodManager } from '../../../../pods/PodManager'; import type { PodManager } from '../../../../pods/PodManager';
import type { PodSettings } from '../../../../pods/settings/PodSettings'; import type { PodSettings } from '../../../../pods/settings/PodSettings';
import type { HttpHandlerInput } from '../../../../server/HttpHandler'; import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import { HttpHandler } from '../../../../server/HttpHandler';
import type { HttpRequest } from '../../../../server/HttpRequest'; import type { HttpRequest } from '../../../../server/HttpRequest';
import type { TemplateHandler } from '../../../../server/util/TemplateHandler';
import type { OwnershipValidator } from '../../../ownership/OwnershipValidator'; import type { OwnershipValidator } from '../../../ownership/OwnershipValidator';
import { getFormDataRequestBody } from '../../util/FormDataUtil'; import { getFormDataRequestBody } from '../../util/FormDataUtil';
import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil'; import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore'; import type { AccountStore } from '../storage/AccountStore';
import type { InteractionResponseResult } from './InteractionHandler';
import { InteractionHandler } from './InteractionHandler';
const emailRegex = /^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/u; const emailRegex = /^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/u;
@ -41,10 +41,6 @@ export interface RegistrationHandlerArgs {
* Creates the new pods. * Creates the new pods.
*/ */
podManager: PodManager; podManager: PodManager;
/**
* Renders the response when registration is successful.
*/
responseHandler: TemplateHandler;
} }
/** /**
@ -87,7 +83,7 @@ interface RegistrationResponse {
* * Ownership will be verified when the WebID is provided. * * Ownership will be verified when the WebID is provided.
* * When registering and creating a pod, the base URL will be used as oidcIssuer value. * * When registering and creating a pod, the base URL will be used as oidcIssuer value.
*/ */
export class RegistrationHandler extends HttpHandler { export class RegistrationHandler extends InteractionHandler {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
private readonly baseUrl: string; private readonly baseUrl: string;
@ -96,7 +92,6 @@ export class RegistrationHandler extends HttpHandler {
private readonly ownershipValidator: OwnershipValidator; private readonly ownershipValidator: OwnershipValidator;
private readonly accountStore: AccountStore; private readonly accountStore: AccountStore;
private readonly podManager: PodManager; private readonly podManager: PodManager;
private readonly responseHandler: TemplateHandler;
public constructor(args: RegistrationHandlerArgs) { public constructor(args: RegistrationHandlerArgs) {
super(); super();
@ -106,15 +101,14 @@ export class RegistrationHandler extends HttpHandler {
this.ownershipValidator = args.ownershipValidator; this.ownershipValidator = args.ownershipValidator;
this.accountStore = args.accountStore; this.accountStore = args.accountStore;
this.podManager = args.podManager; this.podManager = args.podManager;
this.responseHandler = args.responseHandler;
} }
public async handle({ request, response }: HttpHandlerInput): Promise<void> { public async handle({ request }: HttpHandlerInput): Promise<InteractionResponseResult<RegistrationResponse>> {
const result = await this.parseInput(request); const result = await this.parseInput(request);
try { try {
const contents = await this.register(result); const details = await this.register(result);
await this.responseHandler.handleSafe({ response, contents }); return { type: 'response', details };
} catch (error: unknown) { } catch (error: unknown) {
// Don't expose the password field // Don't expose the password field
delete result.password; delete result.password;

View File

@ -1,34 +1,27 @@
import assert from 'assert'; import assert from 'assert';
import { getLoggerFor } from '../../../../logging/LogUtil'; import { getLoggerFor } from '../../../../logging/LogUtil';
import type { HttpHandlerInput } from '../../../../server/HttpHandler'; import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import { HttpHandler } from '../../../../server/HttpHandler';
import type { TemplateHandler } from '../../../../server/util/TemplateHandler';
import { getFormDataRequestBody } from '../../util/FormDataUtil'; import { getFormDataRequestBody } from '../../util/FormDataUtil';
import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil'; import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore'; import type { AccountStore } from '../storage/AccountStore';
import type { InteractionResponseResult } from './InteractionHandler';
export interface ResetPasswordHandlerArgs { import { InteractionHandler } from './InteractionHandler';
accountStore: AccountStore;
messageRenderHandler: TemplateHandler<{ message: string }>;
}
/** /**
* Handles the submission of the ResetPassword form: * Handles the submission of the ResetPassword form:
* this is the form that is linked in the reset password email. * this is the form that is linked in the reset password email.
*/ */
export class ResetPasswordHandler extends HttpHandler { export class ResetPasswordHandler extends InteractionHandler {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
private readonly accountStore: AccountStore; private readonly accountStore: AccountStore;
private readonly messageRenderHandler: TemplateHandler<{ message: string }>;
public constructor(args: ResetPasswordHandlerArgs) { public constructor(accountStore: AccountStore) {
super(); super();
this.accountStore = args.accountStore; this.accountStore = accountStore;
this.messageRenderHandler = args.messageRenderHandler;
} }
public async handle(input: HttpHandlerInput): Promise<void> { public async handle(input: HttpHandlerInput): Promise<InteractionResponseResult> {
try { try {
// Extract record ID from request URL // Extract record ID from request URL
const recordId = /\/([^/]+)$/u.exec(input.request.url!)?.[1]; const recordId = /\/([^/]+)$/u.exec(input.request.url!)?.[1];
@ -41,12 +34,7 @@ export class ResetPasswordHandler extends HttpHandler {
assertPassword(password, confirmPassword); assertPassword(password, confirmPassword);
await this.resetPassword(recordId, password); await this.resetPassword(recordId, password);
await this.messageRenderHandler.handleSafe({ return { type: 'response', details: { message: 'Your password was successfully reset.' }};
response: input.response,
contents: {
message: 'Your password was successfully reset.',
},
});
} catch (error: unknown) { } catch (error: unknown) {
throwIdpInteractionError(error); throwIdpInteractionError(error);
} }

View File

@ -1,12 +0,0 @@
import { TemplateHandler } from '../../../server/util/TemplateHandler';
export interface IdpRenderHandlerProps {
errorMessage?: string;
prefilled?: Record<string, any>;
}
/**
* A special Render Handler that renders an IDP form.
* Contains an error message if something was wrong and prefilled values for forms.
*/
export abstract class IdpRenderHandler extends TemplateHandler<IdpRenderHandlerProps> {}

View File

@ -1,43 +0,0 @@
import type { HttpHandler } from '../../../server/HttpHandler';
import { RouterHandler } from '../../../server/util/RouterHandler';
import { createErrorMessage } from '../../../util/errors/ErrorUtil';
import type { InteractionHttpHandlerInput } from '../InteractionHttpHandler';
import { IdpInteractionError } from './IdpInteractionError';
import type { IdpRenderHandler } from './IdpRenderHandler';
/**
* Handles an IDP interaction route.
* All routes render their UI on a GET and accept POST requests to handle the interaction.
*/
export class IdpRouteController extends RouterHandler {
private readonly renderHandler: IdpRenderHandler;
public constructor(pathName: string, renderHandler: IdpRenderHandler, postHandler: HttpHandler) {
super(postHandler, [ 'GET', 'POST' ], [ pathName ]);
this.renderHandler = renderHandler;
}
/**
* Calls the renderHandler to render using the given response and props.
*/
private async render(input: InteractionHttpHandlerInput, errorMessage = '', prefilled = {}):
Promise<void> {
return this.renderHandler.handleSafe({
response: input.response,
contents: { errorMessage, prefilled },
});
}
public async handle(input: InteractionHttpHandlerInput): Promise<void> {
if (input.request.method === 'GET') {
await this.render(input);
} else if (input.request.method === 'POST') {
try {
await this.handler.handleSafe(input);
} catch (err: unknown) {
const prefilled = IdpInteractionError.isInstance(err) ? err.prefilled : {};
await this.render(input, createErrorMessage(err), prefilled);
}
}
}
}

View File

@ -1,45 +0,0 @@
import urljoin from 'url-join';
import { getLoggerFor } from '../../../logging/LogUtil';
import type { InteractionHttpHandlerInput } from '../InteractionHttpHandler';
import { InteractionHttpHandler } from '../InteractionHttpHandler';
export interface RedirectMap {
[key: string]: string;
default: string;
}
/**
* An {@link InteractionHttpHandler} that redirects requests based on their prompt.
* A list of possible prompts can be found at https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
* In case there is no prompt or there is no match in the input map,
* the `default` redirect will be used.
*
* Specifically, this is used to redirect the client to the correct way to login,
* such as a login page, or a confirmation page if a login procedure already succeeded previously.
*/
export class InitialInteractionHandler extends InteractionHttpHandler {
protected readonly logger = getLoggerFor(this);
private readonly baseUrl: string;
private readonly redirectMap: RedirectMap;
public constructor(baseUrl: string, redirectMap: RedirectMap) {
super();
this.baseUrl = baseUrl;
this.redirectMap = redirectMap;
}
public async handle({ request, response, provider }: InteractionHttpHandlerInput): Promise<void> {
// Find the matching redirect in the map or take the default
const interactionDetails = await provider.interactionDetails(request, response);
const name = interactionDetails.prompt.name in this.redirectMap ? interactionDetails.prompt.name : 'default';
// Create a valid redirect URL
const location = urljoin(this.baseUrl, this.redirectMap[name]);
this.logger.debug(`Redirecting ${name} prompt to ${location}.`);
// Redirect to the result
response.writeHead(302, { location });
response.end();
}
}

View File

@ -1,17 +1,28 @@
import type { InteractionResults } from 'oidc-provider'; import type { InteractionResults } from 'oidc-provider';
import type { HttpHandlerInput } from '../../../server/HttpHandler';
import { AsyncHandler } from '../../../util/handlers/AsyncHandler'; import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
import type { InteractionHttpHandlerInput } from '../InteractionHttpHandler'; import type { ProviderFactory } from '../../configuration/ProviderFactory';
export interface InteractionCompleterInput extends InteractionHttpHandlerInput { export interface InteractionCompleterParams {
webId: string; webId: string;
shouldRemember?: boolean; shouldRemember?: boolean;
} }
export type InteractionCompleterInput = HttpHandlerInput & InteractionCompleterParams;
/** /**
* Completes an IDP interaction, logging the user in. * Completes an IDP interaction, logging the user in.
*/ */
export class InteractionCompleter extends AsyncHandler<InteractionCompleterInput> { export class InteractionCompleter extends AsyncHandler<InteractionCompleterInput> {
private readonly providerFactory: ProviderFactory;
public constructor(providerFactory: ProviderFactory) {
super();
this.providerFactory = providerFactory;
}
public async handle(input: InteractionCompleterInput): Promise<void> { public async handle(input: InteractionCompleterInput): Promise<void> {
const provider = await this.providerFactory.getProvider();
const result: InteractionResults = { const result: InteractionResults = {
login: { login: {
account: input.webId, account: input.webId,
@ -23,6 +34,6 @@ export class InteractionCompleter extends AsyncHandler<InteractionCompleterInput
}, },
}; };
return input.provider.interactionFinished(input.request, input.response, result); return provider.interactionFinished(input.request, input.response, result);
} }
} }

View File

@ -22,6 +22,7 @@ export * from './identity/configuration/IdentityProviderFactory';
export * from './identity/configuration/ProviderFactory'; export * from './identity/configuration/ProviderFactory';
// Identity/Interaction/Email-Password/Handler // Identity/Interaction/Email-Password/Handler
export * from './identity/interaction/email-password/handler/InteractionHandler';
export * from './identity/interaction/email-password/handler/ForgotPasswordHandler'; export * from './identity/interaction/email-password/handler/ForgotPasswordHandler';
export * from './identity/interaction/email-password/handler/LoginHandler'; export * from './identity/interaction/email-password/handler/LoginHandler';
export * from './identity/interaction/email-password/handler/RegistrationHandler'; export * from './identity/interaction/email-password/handler/RegistrationHandler';
@ -39,13 +40,9 @@ export * from './identity/interaction/util/BaseEmailSender';
export * from './identity/interaction/util/EmailSender'; export * from './identity/interaction/util/EmailSender';
export * from './identity/interaction/util/FormDataUtil'; export * from './identity/interaction/util/FormDataUtil';
export * from './identity/interaction/util/IdpInteractionError'; export * from './identity/interaction/util/IdpInteractionError';
export * from './identity/interaction/util/IdpRenderHandler';
export * from './identity/interaction/util/IdpRouteController';
export * from './identity/interaction/util/InitialInteractionHandler';
export * from './identity/interaction/util/InteractionCompleter'; export * from './identity/interaction/util/InteractionCompleter';
// Identity/Interaction // Identity/Interaction
export * from './identity/interaction/InteractionHttpHandler';
export * from './identity/interaction/SessionHttpHandler'; export * from './identity/interaction/SessionHttpHandler';
// Identity/Ownership // Identity/Ownership

View File

@ -7,7 +7,7 @@ import Dict = NodeJS.Dict;
* A Render Handler that uses a template engine to render a response. * A Render Handler that uses a template engine to render a response.
*/ */
export class TemplateHandler<T extends Dict<any> = Dict<any>> export class TemplateHandler<T extends Dict<any> = Dict<any>>
extends AsyncHandler<{ response: HttpResponse; contents: T }> { extends AsyncHandler<{ response: HttpResponse; templateFile: string; contents: T }> {
private readonly templateEngine: TemplateEngine; private readonly templateEngine: TemplateEngine;
private readonly contentType: string; private readonly contentType: string;
@ -17,8 +17,9 @@ export class TemplateHandler<T extends Dict<any> = Dict<any>>
this.contentType = contentType; this.contentType = contentType;
} }
public async handle({ response, contents }: { response: HttpResponse; contents: T }): Promise<void> { public async handle({ response, templateFile, contents }:
const rendered = await this.templateEngine.render(contents); { response: HttpResponse; templateFile: string; contents: T }): Promise<void> {
const rendered = await this.templateEngine.render(contents, { templateFile });
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
response.writeHead(200, { 'Content-Type': this.contentType }); response.writeHead(200, { 'Content-Type': this.contentType });
response.end(rendered); response.end(rendered);

View File

@ -17,7 +17,7 @@
<p>If your account exists, an email has been sent with a link to reset your password.</p> <p>If your account exists, an email has been sent with a link to reset your password.</p>
<p>If you do not receive your email in a couple of minutes, check your spam folder or click the link below to send another email.</p> <p>If you do not receive your email in a couple of minutes, check your spam folder or click the link below to send another email.</p>
<input type="hidden" name="email" value="<%= prefilled.email %>" /> <input type="hidden" name="email" value="<%= email %>" />
<p class="actions"><a href="/idp/login">Back to Log In</a></p> <p class="actions"><a href="/idp/login">Back to Log In</a></p>

View File

@ -87,12 +87,7 @@ export class IdentityTestState {
expect(nextUrl.startsWith(this.oidcIssuer)).toBeTruthy(); expect(nextUrl.startsWith(this.oidcIssuer)).toBeTruthy();
// Need to catch the redirect so we can copy the cookies // Need to catch the redirect so we can copy the cookies
let res = await this.fetchIdp(nextUrl); const res = await this.fetchIdp(nextUrl);
expect(res.status).toBe(302);
nextUrl = res.headers.get('location')!;
// Redirect from main page to specific page (login or confirmation)
res = await this.fetchIdp(nextUrl);
expect(res.status).toBe(302); expect(res.status).toBe(302);
nextUrl = res.headers.get('location')!; nextUrl = res.headers.get('location')!;

View File

@ -1,92 +1,210 @@
import type { Provider } from 'oidc-provider'; import type { Provider } from 'oidc-provider';
import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory'; import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory';
import { IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler'; import { InteractionRoute, IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler';
import type { InteractionHttpHandler } from '../../../src/identity/interaction/InteractionHttpHandler'; import type { InteractionHandler } from '../../../src/identity/interaction/email-password/handler/InteractionHandler';
import { IdpInteractionError } from '../../../src/identity/interaction/util/IdpInteractionError';
import type { InteractionCompleter } from '../../../src/identity/interaction/util/InteractionCompleter';
import type { ErrorHandler } from '../../../src/ldp/http/ErrorHandler'; import type { ErrorHandler } from '../../../src/ldp/http/ErrorHandler';
import type { ResponseDescription } from '../../../src/ldp/http/response/ResponseDescription';
import type { ResponseWriter } from '../../../src/ldp/http/ResponseWriter'; import type { ResponseWriter } from '../../../src/ldp/http/ResponseWriter';
import type { HttpRequest } from '../../../src/server/HttpRequest'; import type { HttpRequest } from '../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../src/server/HttpResponse'; import type { HttpResponse } from '../../../src/server/HttpResponse';
import type { TemplateHandler } from '../../../src/server/util/TemplateHandler';
import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpError';
import { InternalServerError } from '../../../src/util/errors/InternalServerError';
describe('An IdentityProviderHttpHandler', (): void => { describe('An IdentityProviderHttpHandler', (): void => {
const request: HttpRequest = {} as any; const idpPath = '/idp';
let request: HttpRequest;
const response: HttpResponse = {} as any; const response: HttpResponse = {} as any;
let providerFactory: jest.Mocked<ProviderFactory>; let providerFactory: jest.Mocked<ProviderFactory>;
let interactionHttpHandler: jest.Mocked<InteractionHttpHandler>; let routes: { response: InteractionRoute; complete: InteractionRoute };
let interactionCompleter: jest.Mocked<InteractionCompleter>;
let templateHandler: jest.Mocked<TemplateHandler>;
let errorHandler: jest.Mocked<ErrorHandler>; let errorHandler: jest.Mocked<ErrorHandler>;
let responseWriter: jest.Mocked<ResponseWriter>; let responseWriter: jest.Mocked<ResponseWriter>;
let provider: jest.Mocked<Provider>; let provider: jest.Mocked<Provider>;
let handler: IdentityProviderHttpHandler; let handler: IdentityProviderHttpHandler;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
request = { url: '/idp', method: 'GET' } as any;
provider = { provider = {
callback: jest.fn(), callback: jest.fn(),
interactionDetails: jest.fn(),
} as any; } as any;
providerFactory = { providerFactory = {
getProvider: jest.fn().mockResolvedValue(provider), getProvider: jest.fn().mockResolvedValue(provider),
}; };
interactionHttpHandler = { const handlers: InteractionHandler[] = [
canHandle: jest.fn(), { handleSafe: jest.fn().mockResolvedValue({ type: 'response', details: { key: 'val' }}) } as any,
handle: jest.fn(), { handleSafe: jest.fn().mockResolvedValue({ type: 'complete', details: { webId: 'webId' }}) } as any,
} as any; ];
routes = {
response: new InteractionRoute('/routeResponse', '/view1', handlers[0], 'default', '/response1'),
complete: new InteractionRoute('/routeComplete', '/view2', handlers[1], 'other', '/response2'),
};
templateHandler = { handleSafe: jest.fn() } as any;
interactionCompleter = { handleSafe: jest.fn() } as any;
errorHandler = { handleSafe: jest.fn() } as any; errorHandler = { handleSafe: jest.fn() } as any;
responseWriter = { handleSafe: jest.fn() } as any; responseWriter = { handleSafe: jest.fn() } as any;
handler = new IdentityProviderHttpHandler( handler = new IdentityProviderHttpHandler(
idpPath,
providerFactory, providerFactory,
interactionHttpHandler, Object.values(routes),
templateHandler,
interactionCompleter,
errorHandler, errorHandler,
responseWriter, responseWriter,
); );
}); });
it('calls the provider if there is no matching handler.', async(): Promise<void> => { it('errors if the idpPath does not start with a slash.', async(): Promise<void> => {
(interactionHttpHandler.canHandle as jest.Mock).mockRejectedValueOnce(new Error('error!')); expect((): any => new IdentityProviderHttpHandler(
'idp', providerFactory, [], templateHandler, interactionCompleter, errorHandler, responseWriter,
)).toThrow('idpPath needs to start with a /');
});
it('calls the provider if there is no matching route.', async(): Promise<void> => {
request.url = 'invalid';
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(provider.callback).toHaveBeenCalledTimes(1); expect(provider.callback).toHaveBeenCalledTimes(1);
expect(provider.callback).toHaveBeenLastCalledWith(request, response); expect(provider.callback).toHaveBeenLastCalledWith(request, response);
expect(interactionHttpHandler.handle).toHaveBeenCalledTimes(0);
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(0);
}); });
it('calls the interaction handler if it can handle the input.', async(): Promise<void> => { it('calls the templateHandler for matching GET requests.', async(): Promise<void> => {
request.url = '/idp/routeResponse';
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(provider.callback).toHaveBeenCalledTimes(0); expect(templateHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(interactionHttpHandler.handle).toHaveBeenCalledTimes(1); expect(templateHandler.handleSafe).toHaveBeenLastCalledWith(
expect(interactionHttpHandler.handle).toHaveBeenLastCalledWith({ request, response, provider }); { response, templateFile: routes.response.viewTemplate, contents: { errorMessage: '', prefilled: {}}},
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(0); );
}); });
it('returns an error response if there was an issue with the interaction handler.', async(): Promise<void> => { it('calls the templateHandler for InteractionResponseResults.', async(): Promise<void> => {
const error = new Error('error!'); request.url = '/idp/routeResponse';
const errorResponse: ResponseDescription = { statusCode: 500 }; request.method = 'POST';
interactionHttpHandler.handle.mockRejectedValueOnce(error); await expect(handler.handle({ request, response })).resolves.toBeUndefined();
errorHandler.handleSafe.mockResolvedValueOnce(errorResponse); expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1);
expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ request, response });
expect(templateHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(templateHandler.handleSafe).toHaveBeenLastCalledWith(
{ response, templateFile: routes.response.responseTemplate, contents: { key: 'val' }},
);
});
it('calls the interactionCompleter for InteractionCompleteResults.', async(): Promise<void> => {
request.url = '/idp/routeComplete';
request.method = 'POST';
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1);
expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ request, response });
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1);
expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ request, response, webId: 'webId' });
});
it('matches paths based on prompt for requests to the root IDP.', async(): Promise<void> => {
request.url = '/idp';
request.method = 'POST';
provider.interactionDetails.mockResolvedValueOnce({ prompt: { name: 'other' }} as any);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(0);
expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1);
});
it('uses the default route for requests to the root IDP without (matching) prompt.', async(): Promise<void> => {
request.url = '/idp';
request.method = 'POST';
provider.interactionDetails.mockResolvedValueOnce({ prompt: { name: 'notSupported' }} as any);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1);
expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(0);
});
it('displays the viewTemplate again in case of POST errors.', async(): Promise<void> => {
request.url = '/idp/routeResponse';
request.method = 'POST';
(routes.response.handler.handleSafe as any)
.mockRejectedValueOnce(new IdpInteractionError(500, 'handle error', { name: 'name' }));
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(templateHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(templateHandler.handleSafe).toHaveBeenLastCalledWith({
response,
templateFile: routes.response.viewTemplate,
contents: { errorMessage: 'handle error', prefilled: { name: 'name' }},
});
});
it('defaults to an empty prefilled object in case of POST errors.', async(): Promise<void> => {
request.url = '/idp/routeResponse';
request.method = 'POST';
(routes.response.handler.handleSafe as any).mockRejectedValueOnce(new Error('handle error'));
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(templateHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(templateHandler.handleSafe).toHaveBeenLastCalledWith({
response,
templateFile: routes.response.viewTemplate,
contents: { errorMessage: 'handle error', prefilled: { }},
});
});
it('calls the errorHandler if there is a problem resolving the request.', async(): Promise<void> => {
request.url = '/idp/routeResponse';
request.method = 'GET';
const error = new Error('bad template');
templateHandler.handleSafe.mockRejectedValueOnce(error);
errorHandler.handleSafe.mockResolvedValueOnce({ statusCode: 500 });
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(provider.callback).toHaveBeenCalledTimes(0);
expect(interactionHttpHandler.handle).toHaveBeenCalledTimes(1);
expect(interactionHttpHandler.handle).toHaveBeenLastCalledWith({ request, response, provider });
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1); expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/plain': 1 }}}); expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/plain': 1 }}});
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: errorResponse }); expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }});
}); });
it('re-throws the error if it is not a native Error.', async(): Promise<void> => { it('can only resolve GET/POST requests.', async(): Promise<void> => {
interactionHttpHandler.handle.mockRejectedValueOnce('apple!'); request.url = '/idp/routeResponse';
await expect(handler.handle({ request, response })).rejects.toEqual('apple!'); request.method = 'DELETE';
const error = new BadRequestHttpError('Unsupported request: DELETE /idp/routeResponse');
errorHandler.handleSafe.mockResolvedValueOnce({ statusCode: 500 });
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/plain': 1 }}});
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }});
}); });
it('errors if there is an issue creating the provider.', async(): Promise<void> => { it('can only resolve InteractionResponseResult responses if a responseTemplate is set.', async(): Promise<void> => {
const error = new Error('error!'); request.url = '/idp/routeResponse';
providerFactory.getProvider.mockRejectedValueOnce(error); request.method = 'POST';
await expect(handler.handle({ request, response })).rejects.toThrow(error); (routes.response as any).responseTemplate = undefined;
const error = new BadRequestHttpError('Unsupported request: POST /idp/routeResponse');
errorHandler.handleSafe.mockResolvedValueOnce({ statusCode: 500 });
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/plain': 1 }}});
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }});
});
providerFactory.getProvider.mockRejectedValueOnce('apple'); it('errors if no route is configured for the default prompt.', async(): Promise<void> => {
await expect(handler.handle({ request, response })).rejects.toBe('apple'); handler = new IdentityProviderHttpHandler(
idpPath, providerFactory, [], templateHandler, interactionCompleter, errorHandler, responseWriter,
);
request.url = '/idp';
provider.interactionDetails.mockResolvedValueOnce({ prompt: { name: 'other' }} as any);
const error = new InternalServerError('No handler for the default session prompt has been configured.');
errorHandler.handleSafe.mockResolvedValueOnce({ statusCode: 500 });
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/plain': 1 }}});
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }});
}); });
}); });

View File

@ -1,6 +1,6 @@
import type { Provider } from 'oidc-provider'; import type { Provider } from 'oidc-provider';
import type { ProviderFactory } from '../../../../src/identity/configuration/ProviderFactory';
import { SessionHttpHandler } from '../../../../src/identity/interaction/SessionHttpHandler'; import { SessionHttpHandler } from '../../../../src/identity/interaction/SessionHttpHandler';
import type { InteractionCompleter } from '../../../../src/identity/interaction/util/InteractionCompleter';
import type { HttpRequest } from '../../../../src/server/HttpRequest'; import type { HttpRequest } from '../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../src/server/HttpResponse'; import type { HttpResponse } from '../../../../src/server/HttpResponse';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
@ -11,7 +11,6 @@ describe('A SessionHttpHandler', (): void => {
const webId = 'http://test.com/id#me'; const webId = 'http://test.com/id#me';
let details: any = {}; let details: any = {};
let provider: Provider; let provider: Provider;
let oidcInteractionCompleter: InteractionCompleter;
let handler: SessionHttpHandler; let handler: SessionHttpHandler;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
@ -20,31 +19,27 @@ describe('A SessionHttpHandler', (): void => {
interactionDetails: jest.fn().mockResolvedValue(details), interactionDetails: jest.fn().mockResolvedValue(details),
} as any; } as any;
oidcInteractionCompleter = { const factory: ProviderFactory = {
handleSafe: jest.fn(), getProvider: jest.fn().mockResolvedValue(provider),
} as any; };
handler = new SessionHttpHandler(oidcInteractionCompleter); handler = new SessionHttpHandler(factory);
}); });
it('requires a session and accountId.', async(): Promise<void> => { it('requires a session and accountId.', async(): Promise<void> => {
details.session = undefined; details.session = undefined;
await expect(handler.handle({ request, response, provider })).rejects.toThrow(NotImplementedHttpError); await expect(handler.handle({ request, response })).rejects.toThrow(NotImplementedHttpError);
details.session = { accountId: undefined }; details.session = { accountId: undefined };
await expect(handler.handle({ request, response, provider })).rejects.toThrow(NotImplementedHttpError); await expect(handler.handle({ request, response })).rejects.toThrow(NotImplementedHttpError);
}); });
it('calls the oidc completer with the webId in the session.', async(): Promise<void> => { it('calls the oidc completer with the webId in the session.', async(): Promise<void> => {
await expect(handler.handle({ request, response, provider })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves.toEqual({
details: { webId },
type: 'complete',
});
expect(provider.interactionDetails).toHaveBeenCalledTimes(1); expect(provider.interactionDetails).toHaveBeenCalledTimes(1);
expect(provider.interactionDetails).toHaveBeenLastCalledWith(request, response); expect(provider.interactionDetails).toHaveBeenLastCalledWith(request, response);
expect(oidcInteractionCompleter.handleSafe).toHaveBeenCalledTimes(1);
expect(oidcInteractionCompleter.handleSafe).toHaveBeenLastCalledWith({
request,
response,
provider,
webId,
});
}); });
}); });

View File

@ -1,10 +1,8 @@
import type { Provider } from 'oidc-provider';
import { import {
ForgotPasswordHandler, ForgotPasswordHandler,
} from '../../../../../../src/identity/interaction/email-password/handler/ForgotPasswordHandler'; } from '../../../../../../src/identity/interaction/email-password/handler/ForgotPasswordHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender'; import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender';
import type { IdpRenderHandler } from '../../../../../../src/identity/interaction/util/IdpRenderHandler';
import type { HttpRequest } from '../../../../../../src/server/HttpRequest'; import type { HttpRequest } from '../../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../../src/server/HttpResponse'; import type { HttpResponse } from '../../../../../../src/server/HttpResponse';
import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine'; import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine';
@ -16,9 +14,6 @@ describe('A ForgotPasswordHandler', (): void => {
const email = 'test@test.email'; const email = 'test@test.email';
const recordId = '123456'; const recordId = '123456';
const html = `<a href="/base/idp/resetpassword/${recordId}">Reset Password</a>`; const html = `<a href="/base/idp/resetpassword/${recordId}">Reset Password</a>`;
const renderParams = { response, contents: { errorMessage: '', prefilled: { email }}};
const provider: Provider = {} as any;
let messageRenderHandler: IdpRenderHandler;
let accountStore: AccountStore; let accountStore: AccountStore;
const baseUrl = 'http://test.com/base/'; const baseUrl = 'http://test.com/base/';
const idpPath = '/idp'; const idpPath = '/idp';
@ -29,10 +24,6 @@ describe('A ForgotPasswordHandler', (): void => {
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
request = createPostFormRequest({ email }); request = createPostFormRequest({ email });
messageRenderHandler = {
handleSafe: jest.fn(),
} as any;
accountStore = { accountStore = {
generateForgotPasswordRecord: jest.fn().mockResolvedValue(recordId), generateForgotPasswordRecord: jest.fn().mockResolvedValue(recordId),
} as any; } as any;
@ -46,7 +37,6 @@ describe('A ForgotPasswordHandler', (): void => {
} as any; } as any;
handler = new ForgotPasswordHandler({ handler = new ForgotPasswordHandler({
messageRenderHandler,
accountStore, accountStore,
baseUrl, baseUrl,
idpPath, idpPath,
@ -57,21 +47,21 @@ describe('A ForgotPasswordHandler', (): void => {
it('errors on non-string emails.', async(): Promise<void> => { it('errors on non-string emails.', async(): Promise<void> => {
request = createPostFormRequest({}); request = createPostFormRequest({});
await expect(handler.handle({ request, response, provider })).rejects.toThrow('Email required'); await expect(handler.handle({ request, response })).rejects.toThrow('Email required');
request = createPostFormRequest({ email: [ 'email', 'email2' ]}); request = createPostFormRequest({ email: [ 'email', 'email2' ]});
await expect(handler.handle({ request, response, provider })).rejects.toThrow('Email required'); await expect(handler.handle({ request, response })).rejects.toThrow('Email required');
}); });
it('does not send a mail if a ForgotPassword record could not be generated.', async(): Promise<void> => { it('does not send a mail if a ForgotPassword record could not be generated.', async(): Promise<void> => {
(accountStore.generateForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('error'); (accountStore.generateForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('error');
await expect(handler.handle({ request, response, provider })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves
.toEqual({ type: 'response', details: { email }});
expect(emailSender.handleSafe).toHaveBeenCalledTimes(0); expect(emailSender.handleSafe).toHaveBeenCalledTimes(0);
expect(messageRenderHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(messageRenderHandler.handleSafe).toHaveBeenLastCalledWith(renderParams);
}); });
it('sends a mail if a ForgotPassword record could be generated.', async(): Promise<void> => { it('sends a mail if a ForgotPassword record could be generated.', async(): Promise<void> => {
await expect(handler.handle({ request, response, provider })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves
.toEqual({ type: 'response', details: { email }});
expect(emailSender.handleSafe).toHaveBeenCalledTimes(1); expect(emailSender.handleSafe).toHaveBeenCalledTimes(1);
expect(emailSender.handleSafe).toHaveBeenLastCalledWith({ expect(emailSender.handleSafe).toHaveBeenLastCalledWith({
recipient: email, recipient: email,
@ -79,7 +69,5 @@ describe('A ForgotPasswordHandler', (): void => {
text: `To reset your password, go to this link: http://test.com/base/idp/resetpassword/${recordId}`, text: `To reset your password, go to this link: http://test.com/base/idp/resetpassword/${recordId}`,
html, html,
}); });
expect(messageRenderHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(messageRenderHandler.handleSafe).toHaveBeenLastCalledWith(renderParams);
}); });
}); });

View File

@ -1,15 +1,13 @@
import { LoginHandler } from '../../../../../../src/identity/interaction/email-password/handler/LoginHandler'; import { LoginHandler } from '../../../../../../src/identity/interaction/email-password/handler/LoginHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { InteractionHttpHandlerInput } from '../../../../../../src/identity/interaction/InteractionHttpHandler'; import type { HttpHandlerInput } from '../../../../../../src/server/HttpHandler';
import type { InteractionCompleter } from '../../../../../../src/identity/interaction/util/InteractionCompleter';
import { createPostFormRequest } from './Util'; import { createPostFormRequest } from './Util';
describe('A LoginHandler', (): void => { describe('A LoginHandler', (): void => {
const webId = 'http://alice.test.com/card#me'; const webId = 'http://alice.test.com/card#me';
const email = 'alice@test.email'; const email = 'alice@test.email';
let input: InteractionHttpHandlerInput; let input: HttpHandlerInput;
let storageAdapter: AccountStore; let storageAdapter: AccountStore;
let interactionCompleter: InteractionCompleter;
let handler: LoginHandler; let handler: LoginHandler;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
@ -19,11 +17,7 @@ describe('A LoginHandler', (): void => {
authenticate: jest.fn().mockResolvedValue(webId), authenticate: jest.fn().mockResolvedValue(webId),
} as any; } as any;
interactionCompleter = { handler = new LoginHandler(storageAdapter);
handleSafe: jest.fn(),
} as any;
handler = new LoginHandler({ accountStore: storageAdapter, interactionCompleter });
}); });
it('errors on invalid emails.', async(): Promise<void> => { it('errors on invalid emails.', async(): Promise<void> => {
@ -56,13 +50,13 @@ describe('A LoginHandler', (): void => {
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }})); await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }}));
}); });
it('calls the OidcInteractionCompleter when done.', async(): Promise<void> => { it('returns an InteractionCompleteResult when done.', async(): Promise<void> => {
input.request = createPostFormRequest({ email, password: 'password!' }); input.request = createPostFormRequest({ email, password: 'password!' });
await expect(handler.handle(input)).resolves.toBeUndefined(); await expect(handler.handle(input)).resolves.toEqual({
type: 'complete',
details: { webId, shouldRemember: false },
});
expect(storageAdapter.authenticate).toHaveBeenCalledTimes(1); expect(storageAdapter.authenticate).toHaveBeenCalledTimes(1);
expect(storageAdapter.authenticate).toHaveBeenLastCalledWith(email, 'password!'); expect(storageAdapter.authenticate).toHaveBeenLastCalledWith(email, 'password!');
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1);
expect(interactionCompleter.handleSafe)
.toHaveBeenLastCalledWith({ ...input, webId, shouldRemember: false });
}); });
}); });

View File

@ -11,7 +11,6 @@ import type { PodManager } from '../../../../../../src/pods/PodManager';
import type { PodSettings } from '../../../../../../src/pods/settings/PodSettings'; import type { PodSettings } from '../../../../../../src/pods/settings/PodSettings';
import type { HttpRequest } from '../../../../../../src/server/HttpRequest'; import type { HttpRequest } from '../../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../../src/server/HttpResponse'; import type { HttpResponse } from '../../../../../../src/server/HttpResponse';
import type { TemplateHandler } from '../../../../../../src/server/util/TemplateHandler';
import { createPostFormRequest } from './Util'; import { createPostFormRequest } from './Util';
describe('A RegistrationHandler', (): void => { describe('A RegistrationHandler', (): void => {
@ -37,7 +36,6 @@ describe('A RegistrationHandler', (): void => {
let ownershipValidator: OwnershipValidator; let ownershipValidator: OwnershipValidator;
let accountStore: AccountStore; let accountStore: AccountStore;
let podManager: PodManager; let podManager: PodManager;
let responseHandler: TemplateHandler<NodeJS.Dict<any>>;
let handler: RegistrationHandler; let handler: RegistrationHandler;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
@ -61,10 +59,6 @@ describe('A RegistrationHandler', (): void => {
createPod: jest.fn(), createPod: jest.fn(),
}; };
responseHandler = {
handleSafe: jest.fn(),
} as any;
handler = new RegistrationHandler({ handler = new RegistrationHandler({
baseUrl, baseUrl,
webIdSuffix, webIdSuffix,
@ -72,7 +66,6 @@ describe('A RegistrationHandler', (): void => {
accountStore, accountStore,
ownershipValidator, ownershipValidator,
podManager, podManager,
responseHandler,
}); });
}); });
@ -151,7 +144,17 @@ describe('A RegistrationHandler', (): void => {
describe('handling data', (): void => { describe('handling data', (): void => {
it('can register a user.', async(): Promise<void> => { it('can register a user.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, password, confirmPassword, register }); request = createPostFormRequest({ email, webId, password, confirmPassword, register });
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves.toEqual({
details: {
email,
webId,
oidcIssuer: baseUrl,
createWebId: false,
register: true,
createPod: false,
},
type: 'response',
});
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
@ -168,7 +171,18 @@ describe('A RegistrationHandler', (): void => {
it('can create a pod.', async(): Promise<void> => { it('can create a pod.', async(): Promise<void> => {
const params = { email, webId, podName, createPod }; const params = { email, webId, podName, createPod };
request = createPostFormRequest(params); request = createPostFormRequest(params);
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves.toEqual({
details: {
email,
webId,
oidcIssuer: baseUrl,
podBaseUrl: `${baseUrl}${podName}/`,
createWebId: false,
register: false,
createPod: true,
},
type: 'response',
});
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
@ -186,7 +200,18 @@ describe('A RegistrationHandler', (): void => {
const params = { email, webId, password, confirmPassword, podName, register, createPod }; const params = { email, webId, password, confirmPassword, podName, register, createPod };
podSettings.oidcIssuer = baseUrl; podSettings.oidcIssuer = baseUrl;
request = createPostFormRequest(params); request = createPostFormRequest(params);
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves.toEqual({
details: {
email,
webId,
oidcIssuer: baseUrl,
podBaseUrl: `${baseUrl}${podName}/`,
createWebId: false,
register: true,
createPod: true,
},
type: 'response',
});
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
@ -225,12 +250,23 @@ describe('A RegistrationHandler', (): void => {
it('can create a WebID with an account and pod.', async(): Promise<void> => { it('can create a WebID with an account and pod.', async(): Promise<void> => {
const params = { email, password, confirmPassword, podName, createWebId, register, createPod }; const params = { email, password, confirmPassword, podName, createWebId, register, createPod };
podSettings.oidcIssuer = baseUrl;
request = createPostFormRequest(params);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
const generatedWebID = urljoin(baseUrl, podName, webIdSuffix); const generatedWebID = urljoin(baseUrl, podName, webIdSuffix);
podSettings.webId = generatedWebID; podSettings.webId = generatedWebID;
podSettings.oidcIssuer = baseUrl;
request = createPostFormRequest(params);
await expect(handler.handle({ request, response })).resolves.toEqual({
details: {
email,
webId: generatedWebID,
oidcIssuer: baseUrl,
podBaseUrl: `${baseUrl}${podName}/`,
createWebId: true,
register: true,
createPod: true,
},
type: 'response',
});
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);

View File

@ -4,7 +4,6 @@ import {
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { HttpRequest } from '../../../../../../src/server/HttpRequest'; import type { HttpRequest } from '../../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../../src/server/HttpResponse'; import type { HttpResponse } from '../../../../../../src/server/HttpResponse';
import type { TemplateHandler } from '../../../../../../src/server/util/TemplateHandler';
import { createPostFormRequest } from './Util'; import { createPostFormRequest } from './Util';
describe('A ResetPasswordHandler', (): void => { describe('A ResetPasswordHandler', (): void => {
@ -14,7 +13,6 @@ describe('A ResetPasswordHandler', (): void => {
const url = `/resetURL/${recordId}`; const url = `/resetURL/${recordId}`;
const email = 'alice@test.email'; const email = 'alice@test.email';
let accountStore: AccountStore; let accountStore: AccountStore;
let messageRenderHandler: TemplateHandler<{ message: string }>;
let handler: ResetPasswordHandler; let handler: ResetPasswordHandler;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
@ -24,14 +22,7 @@ describe('A ResetPasswordHandler', (): void => {
changePassword: jest.fn(), changePassword: jest.fn(),
} as any; } as any;
messageRenderHandler = { handler = new ResetPasswordHandler(accountStore);
handleSafe: jest.fn(),
} as any;
handler = new ResetPasswordHandler({
accountStore,
messageRenderHandler,
});
}); });
it('errors for non-string recordIds.', async(): Promise<void> => { it('errors for non-string recordIds.', async(): Promise<void> => {
@ -57,16 +48,16 @@ describe('A ResetPasswordHandler', (): void => {
it('renders a message on success.', async(): Promise<void> => { it('renders a message on success.', async(): Promise<void> => {
request = createPostFormRequest({ password: 'password!', confirmPassword: 'password!' }, url); request = createPostFormRequest({ password: 'password!', confirmPassword: 'password!' }, url);
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves.toEqual({
details: { message: 'Your password was successfully reset.' },
type: 'response',
});
expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1); expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1);
expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId); expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId);
expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1); expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1);
expect(accountStore.deleteForgotPasswordRecord).toHaveBeenLastCalledWith(recordId); expect(accountStore.deleteForgotPasswordRecord).toHaveBeenLastCalledWith(recordId);
expect(accountStore.changePassword).toHaveBeenCalledTimes(1); expect(accountStore.changePassword).toHaveBeenCalledTimes(1);
expect(accountStore.changePassword).toHaveBeenLastCalledWith(email, 'password!'); expect(accountStore.changePassword).toHaveBeenLastCalledWith(email, 'password!');
expect(messageRenderHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(messageRenderHandler.handleSafe)
.toHaveBeenLastCalledWith({ response, contents: { message: 'Your password was successfully reset.' }});
}); });
it('has a default error for non-native errors.', async(): Promise<void> => { it('has a default error for non-native errors.', async(): Promise<void> => {

View File

@ -1,87 +0,0 @@
import type { Provider } from 'oidc-provider';
import { IdpInteractionError } from '../../../../../src/identity/interaction/util/IdpInteractionError';
import type { IdpRenderHandler } from '../../../../../src/identity/interaction/util/IdpRenderHandler';
import {
IdpRouteController,
} from '../../../../../src/identity/interaction/util/IdpRouteController';
import type { HttpHandler } from '../../../../../src/server/HttpHandler';
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../src/server/HttpResponse';
describe('An IdpRouteController', (): void => {
let request: HttpRequest;
const response: HttpResponse = {} as any;
const provider: Provider = {} as any;
let renderHandler: IdpRenderHandler;
let postHandler: HttpHandler;
let controller: IdpRouteController;
beforeEach(async(): Promise<void> => {
request = {
randomData: 'data!',
method: 'GET',
} as any;
renderHandler = {
handleSafe: jest.fn(),
} as any;
postHandler = {
handleSafe: jest.fn(),
} as any;
controller = new IdpRouteController('pathName', renderHandler, postHandler);
});
it('renders the renderHandler for GET requests.', async(): Promise<void> => {
await expect(controller.handle({ request, response, provider })).resolves.toBeUndefined();
expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({
response,
contents: { errorMessage: '', prefilled: {}},
});
expect(postHandler.handleSafe).toHaveBeenCalledTimes(0);
});
it('calls the postHandler for POST requests.', async(): Promise<void> => {
request.method = 'POST';
await expect(controller.handle({ request, response, provider })).resolves.toBeUndefined();
expect(renderHandler.handleSafe).toHaveBeenCalledTimes(0);
expect(postHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(postHandler.handleSafe).toHaveBeenLastCalledWith({ request, response, provider });
});
it('renders an error if the POST request failed.', async(): Promise<void> => {
request.method = 'POST';
const error = new IdpInteractionError(400, 'bad request!', { more: 'data!' });
(postHandler.handleSafe as jest.Mock).mockRejectedValueOnce(error);
await expect(controller.handle({ request, response, provider })).resolves.toBeUndefined();
expect(postHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(postHandler.handleSafe).toHaveBeenLastCalledWith({ request, response, provider });
expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({
response,
contents: { errorMessage: 'bad request!', prefilled: { more: 'data!' }},
});
});
it('has a default error message if none is provided.', async(): Promise<void> => {
request.method = 'POST';
(postHandler.handleSafe as jest.Mock).mockRejectedValueOnce('apple!');
await expect(controller.handle({ request, response, provider })).resolves.toBeUndefined();
expect(postHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(postHandler.handleSafe).toHaveBeenLastCalledWith({ request, response, provider });
expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({
response,
contents: { errorMessage: 'Unknown error: apple!', prefilled: {}},
});
});
it('does nothing for other methods.', async(): Promise<void> => {
request.method = 'DELETE';
await expect(controller.handle({ request, response, provider })).resolves.toBeUndefined();
expect(postHandler.handleSafe).toHaveBeenCalledTimes(0);
expect(renderHandler.handleSafe).toHaveBeenCalledTimes(0);
});
});

View File

@ -1,52 +0,0 @@
import type { MockResponse } from 'node-mocks-http';
import { createResponse } from 'node-mocks-http';
import type { Provider } from 'oidc-provider';
import type { RedirectMap } from '../../../../../src/identity/interaction/util/InitialInteractionHandler';
import { InitialInteractionHandler } from '../../../../../src/identity/interaction/util/InitialInteractionHandler';
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
describe('An InitialInteractionHandler', (): void => {
const baseUrl = 'http://test.com/';
const request: HttpRequest = {} as any;
let response: MockResponse<any>;
let provider: jest.Mocked<Provider>;
// `Interaction` type is not exposed
let details: any;
let map: RedirectMap;
let handler: InitialInteractionHandler;
beforeEach(async(): Promise<void> => {
response = createResponse();
map = {
default: '/idp/login',
test: '/idp/test',
};
details = { prompt: { name: 'test' }};
provider = {
interactionDetails: jest.fn().mockResolvedValue(details),
} as any;
handler = new InitialInteractionHandler(baseUrl, map);
});
it('uses the named handler if it is found.', async(): Promise<void> => {
await expect(handler.handle({ request, response, provider })).resolves.toBeUndefined();
expect(provider.interactionDetails).toHaveBeenCalledTimes(1);
expect(provider.interactionDetails).toHaveBeenLastCalledWith(request, response);
expect(response._isEndCalled()).toBe(true);
expect(response.getHeader('location')).toBe('http://test.com/idp/test');
expect(response.statusCode).toBe(302);
});
it('uses the default handler if there is no match.', async(): Promise<void> => {
details.prompt.name = 'unknown';
await expect(handler.handle({ request, response, provider })).resolves.toBeUndefined();
expect(provider.interactionDetails).toHaveBeenCalledTimes(1);
expect(provider.interactionDetails).toHaveBeenLastCalledWith(request, response);
expect(response._isEndCalled()).toBe(true);
expect(response.getHeader('location')).toBe('http://test.com/idp/login');
expect(response.statusCode).toBe(302);
});
});

View File

@ -1,4 +1,5 @@
import type { Provider } from 'oidc-provider'; import type { Provider } from 'oidc-provider';
import type { ProviderFactory } from '../../../../../src/identity/configuration/ProviderFactory';
import { InteractionCompleter } from '../../../../../src/identity/interaction/util/InteractionCompleter'; import { InteractionCompleter } from '../../../../../src/identity/interaction/util/InteractionCompleter';
import type { HttpRequest } from '../../../../../src/server/HttpRequest'; import type { HttpRequest } from '../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../src/server/HttpResponse'; import type { HttpResponse } from '../../../../../src/server/HttpResponse';
@ -11,16 +12,22 @@ describe('An InteractionCompleter', (): void => {
const response: HttpResponse = {} as any; const response: HttpResponse = {} as any;
const webId = 'http://alice.test.com/#me'; const webId = 'http://alice.test.com/#me';
let provider: Provider; let provider: Provider;
const completer = new InteractionCompleter(); let completer: InteractionCompleter;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
provider = { provider = {
interactionFinished: jest.fn(), interactionFinished: jest.fn(),
} as any; } as any;
const factory: ProviderFactory = {
getProvider: jest.fn().mockResolvedValue(provider),
};
completer = new InteractionCompleter(factory);
}); });
it('sends the correct data to the provider.', async(): Promise<void> => { it('sends the correct data to the provider.', async(): Promise<void> => {
await expect(completer.handle({ request, response, provider, webId, shouldRemember: true })) await expect(completer.handle({ request, response, webId, shouldRemember: true }))
.resolves.toBeUndefined(); .resolves.toBeUndefined();
expect(provider.interactionFinished).toHaveBeenCalledTimes(1); expect(provider.interactionFinished).toHaveBeenCalledTimes(1);
expect(provider.interactionFinished).toHaveBeenLastCalledWith(request, response, { expect(provider.interactionFinished).toHaveBeenLastCalledWith(request, response, {
@ -36,7 +43,7 @@ describe('An InteractionCompleter', (): void => {
}); });
it('rejects offline access if shouldRemember is false.', async(): Promise<void> => { it('rejects offline access if shouldRemember is false.', async(): Promise<void> => {
await expect(completer.handle({ request, response, provider, webId, shouldRemember: false })) await expect(completer.handle({ request, response, webId, shouldRemember: false }))
.resolves.toBeUndefined(); .resolves.toBeUndefined();
expect(provider.interactionFinished).toHaveBeenCalledTimes(1); expect(provider.interactionFinished).toHaveBeenCalledTimes(1);
expect(provider.interactionFinished).toHaveBeenLastCalledWith(request, response, { expect(provider.interactionFinished).toHaveBeenLastCalledWith(request, response, {

View File

@ -5,6 +5,7 @@ import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngi
describe('A TemplateHandler', (): void => { describe('A TemplateHandler', (): void => {
const contents = { contents: 'contents' }; const contents = { contents: 'contents' };
const templateFile = '/templates/main.html.ejs';
let templateEngine: jest.Mocked<TemplateEngine>; let templateEngine: jest.Mocked<TemplateEngine>;
let response: HttpResponse; let response: HttpResponse;
@ -17,10 +18,10 @@ describe('A TemplateHandler', (): void => {
it('renders the template in the response.', async(): Promise<void> => { it('renders the template in the response.', async(): Promise<void> => {
const handler = new TemplateHandler(templateEngine); const handler = new TemplateHandler(templateEngine);
await handler.handle({ response, contents }); await handler.handle({ response, contents, templateFile });
expect(templateEngine.render).toHaveBeenCalledTimes(1); expect(templateEngine.render).toHaveBeenCalledTimes(1);
expect(templateEngine.render).toHaveBeenCalledWith(contents); expect(templateEngine.render).toHaveBeenCalledWith(contents, { templateFile });
expect(response.getHeaders()).toHaveProperty('content-type', 'text/html'); expect(response.getHeaders()).toHaveProperty('content-type', 'text/html');
expect((response as any)._isEndCalled()).toBe(true); expect((response as any)._isEndCalled()).toBe(true);