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",
"import": [
"files-scs:config/identity/handler/account-store/default.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/provider-factory/identity.json"
],
@ -18,8 +19,17 @@
{
"@id": "urn:solid-server:default:IdentityProviderHttpHandler",
"@type": "IdentityProviderHttpHandler",
"idpPath": "/idp",
"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" },
"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",
"import": [
"files-scs:config/identity/registration/handler/registration.json"
"files-scs:config/identity/registration/route/registration.json"
],
"@graph": [
{
"comment": "Enable registration by adding a registration handler to the list of interaction handlers.",
"@id": "urn:solid-server:auth:password:InteractionHttpHandler",
"WaterfallHandler:_handlers": [
{ "@id": "urn:solid-server:auth:password:RegistrationInteractionHandler" }
"comment": "Enable registration by adding a registration handler to the list of interaction routes.",
"@id": "urn:solid-server:default:IdentityProviderHttpHandler",
"IdentityProviderHttpHandler:_interactionRoutes": [
{ "@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 type { HttpHandlerInput } 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 { 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,
* by sending them to either the stored {@link InteractionHttpHandler},
* or the generated Provider from the {@link ProviderFactory} if the first does not support the request.
* by sending them to either a matching {@link InteractionRoute},
* 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.
* 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 {
protected readonly logger = getLoggerFor(this);
private readonly idpPath: string;
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 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(
idpPath: string,
providerFactory: ProviderFactory,
interactionHttpHandler: InteractionHttpHandler,
interactionRoutes: InteractionRoute[],
templateHandler: TemplateHandler,
interactionCompleter: InteractionCompleter,
errorHandler: ErrorHandler,
responseWriter: ResponseWriter,
) {
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.interactionHttpHandler = interactionHttpHandler;
this.interactionRoutes = interactionRoutes;
this.templateHandler = templateHandler;
this.interactionCompleter = interactionCompleter;
this.errorHandler = errorHandler;
this.responseWriter = responseWriter;
}
public async handle(input: 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
public async handle({ request, response }: HttpHandlerInput): Promise<void> {
try {
await this.interactionHttpHandler.canHandle({ ...input, provider });
} 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 });
await this.handleRequest(request, response);
} catch (error: unknown) {
assertError(error);
// 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 }}});
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 type { InteractionHttpHandlerInput } from './InteractionHttpHandler';
import { InteractionHttpHandler } from './InteractionHttpHandler';
import type { InteractionCompleter } from './util/InteractionCompleter';
import type { ProviderFactory } from '../configuration/ProviderFactory';
import { InteractionHandler } from './email-password/handler/InteractionHandler';
import type { InteractionCompleteResult } from './email-password/handler/InteractionHandler';
/**
* Simple InteractionHttpHandler that sends the session accountId to the InteractionCompleter as webId.
*/
export class SessionHttpHandler extends InteractionHttpHandler {
private readonly interactionCompleter: InteractionCompleter;
export class SessionHttpHandler extends InteractionHandler {
private readonly providerFactory: ProviderFactory;
public constructor(interactionCompleter: InteractionCompleter) {
public constructor(providerFactory: ProviderFactory) {
super();
this.interactionCompleter = interactionCompleter;
this.providerFactory = providerFactory;
}
public async handle(input: InteractionHttpHandlerInput): Promise<void> {
const details = await input.provider.interactionDetails(input.request, input.response);
public async handle(input: HttpHandlerInput): Promise<InteractionCompleteResult> {
const provider = await this.providerFactory.getProvider();
const details = await provider.interactionDetails(input.request, input.response);
if (!details.session || !details.session.accountId) {
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 urljoin from 'url-join';
import { getLoggerFor } from '../../../../logging/LogUtil';
import type { HttpResponse } from '../../../../server/HttpResponse';
import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import { ensureTrailingSlash } from '../../../../util/PathUtil';
import type { TemplateEngine } from '../../../../util/templates/TemplateEngine';
import type { InteractionHttpHandlerInput } from '../../InteractionHttpHandler';
import { InteractionHttpHandler } from '../../InteractionHttpHandler';
import type { EmailSender } from '../../util/EmailSender';
import { getFormDataRequestBody } from '../../util/FormDataUtil';
import type { IdpRenderHandler } from '../../util/IdpRenderHandler';
import { throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore';
import { InteractionHandler } from './InteractionHandler';
import type { InteractionResponseResult } from './InteractionHandler';
export interface ForgotPasswordHandlerArgs {
messageRenderHandler: IdpRenderHandler;
accountStore: AccountStore;
baseUrl: string;
idpPath: string;
@ -24,10 +22,9 @@ export interface ForgotPasswordHandlerArgs {
/**
* Handles the submission of the ForgotPassword form
*/
export class ForgotPasswordHandler extends InteractionHttpHandler {
export class ForgotPasswordHandler extends InteractionHandler {
protected readonly logger = getLoggerFor(this);
private readonly messageRenderHandler: IdpRenderHandler;
private readonly accountStore: AccountStore;
private readonly baseUrl: string;
private readonly idpPath: string;
@ -36,7 +33,6 @@ export class ForgotPasswordHandler extends InteractionHttpHandler {
public constructor(args: ForgotPasswordHandlerArgs) {
super();
this.messageRenderHandler = args.messageRenderHandler;
this.accountStore = args.accountStore;
this.baseUrl = ensureTrailingSlash(args.baseUrl);
this.idpPath = args.idpPath;
@ -44,14 +40,14 @@ export class ForgotPasswordHandler extends InteractionHttpHandler {
this.emailSender = args.emailSender;
}
public async handle(input: InteractionHttpHandlerInput): Promise<void> {
public async handle(input: HttpHandlerInput): Promise<InteractionResponseResult<{ email: string }>> {
try {
// Validate incoming data
const { email } = await getFormDataRequestBody(input.request);
assert(typeof email === 'string' && email.length > 0, 'Email required');
await this.resetPassword(email);
await this.sendResponse(input.response, email);
return { type: 'response', details: { email }};
} catch (err: unknown) {
throwIdpInteractionError(err, {});
}
@ -88,22 +84,4 @@ export class ForgotPasswordHandler extends InteractionHttpHandler {
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 { getLoggerFor } from '../../../../logging/LogUtil';
import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import type { HttpRequest } from '../../../../server/HttpRequest';
import type { InteractionHttpHandlerInput } from '../../InteractionHttpHandler';
import { InteractionHttpHandler } from '../../InteractionHttpHandler';
import { getFormDataRequestBody } from '../../util/FormDataUtil';
import type { InteractionCompleter } from '../../util/InteractionCompleter';
import { throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore';
export interface LoginHandlerArgs {
accountStore: AccountStore;
interactionCompleter: InteractionCompleter;
}
import { InteractionHandler } from './InteractionHandler';
import type { InteractionCompleteResult } from './InteractionHandler';
/**
* 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);
private readonly accountStore: AccountStore;
private readonly interactionCompleter: InteractionCompleter;
public constructor(args: LoginHandlerArgs) {
public constructor(accountStore: AccountStore) {
super();
this.accountStore = args.accountStore;
this.interactionCompleter = args.interactionCompleter;
this.accountStore = accountStore;
}
public async handle(input: InteractionHttpHandlerInput): Promise<void> {
public async handle(input: HttpHandlerInput): Promise<InteractionCompleteResult> {
const { email, password, remember } = await this.parseInput(input.request);
try {
// Try to log in, will error if email/password combination is invalid
const webId = await this.accountStore.authenticate(email, password);
await this.interactionCompleter.handleSafe({ ...input, webId, shouldRemember: Boolean(remember) });
this.logger.debug(`Logging in user ${email}`);
return {
type: 'complete',
details: { webId, shouldRemember: Boolean(remember) },
};
} catch (err: unknown) {
throwIdpInteractionError(err, { email });
}

View File

@ -6,13 +6,13 @@ import type { IdentifierGenerator } from '../../../../pods/generate/IdentifierGe
import type { PodManager } from '../../../../pods/PodManager';
import type { PodSettings } from '../../../../pods/settings/PodSettings';
import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import { HttpHandler } from '../../../../server/HttpHandler';
import type { HttpRequest } from '../../../../server/HttpRequest';
import type { TemplateHandler } from '../../../../server/util/TemplateHandler';
import type { OwnershipValidator } from '../../../ownership/OwnershipValidator';
import { getFormDataRequestBody } from '../../util/FormDataUtil';
import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil';
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;
@ -41,10 +41,6 @@ export interface RegistrationHandlerArgs {
* Creates the new pods.
*/
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.
* * 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);
private readonly baseUrl: string;
@ -96,7 +92,6 @@ export class RegistrationHandler extends HttpHandler {
private readonly ownershipValidator: OwnershipValidator;
private readonly accountStore: AccountStore;
private readonly podManager: PodManager;
private readonly responseHandler: TemplateHandler;
public constructor(args: RegistrationHandlerArgs) {
super();
@ -106,15 +101,14 @@ export class RegistrationHandler extends HttpHandler {
this.ownershipValidator = args.ownershipValidator;
this.accountStore = args.accountStore;
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);
try {
const contents = await this.register(result);
await this.responseHandler.handleSafe({ response, contents });
const details = await this.register(result);
return { type: 'response', details };
} catch (error: unknown) {
// Don't expose the password field
delete result.password;

View File

@ -1,34 +1,27 @@
import assert from 'assert';
import { getLoggerFor } from '../../../../logging/LogUtil';
import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import { HttpHandler } from '../../../../server/HttpHandler';
import type { TemplateHandler } from '../../../../server/util/TemplateHandler';
import { getFormDataRequestBody } from '../../util/FormDataUtil';
import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore';
export interface ResetPasswordHandlerArgs {
accountStore: AccountStore;
messageRenderHandler: TemplateHandler<{ message: string }>;
}
import type { InteractionResponseResult } from './InteractionHandler';
import { InteractionHandler } from './InteractionHandler';
/**
* Handles the submission of the ResetPassword form:
* 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);
private readonly accountStore: AccountStore;
private readonly messageRenderHandler: TemplateHandler<{ message: string }>;
public constructor(args: ResetPasswordHandlerArgs) {
public constructor(accountStore: AccountStore) {
super();
this.accountStore = args.accountStore;
this.messageRenderHandler = args.messageRenderHandler;
this.accountStore = accountStore;
}
public async handle(input: HttpHandlerInput): Promise<void> {
public async handle(input: HttpHandlerInput): Promise<InteractionResponseResult> {
try {
// Extract record ID from request URL
const recordId = /\/([^/]+)$/u.exec(input.request.url!)?.[1];
@ -41,12 +34,7 @@ export class ResetPasswordHandler extends HttpHandler {
assertPassword(password, confirmPassword);
await this.resetPassword(recordId, password);
await this.messageRenderHandler.handleSafe({
response: input.response,
contents: {
message: 'Your password was successfully reset.',
},
});
return { type: 'response', details: { message: 'Your password was successfully reset.' }};
} catch (error: unknown) {
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 { HttpHandlerInput } from '../../../server/HttpHandler';
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;
shouldRemember?: boolean;
}
export type InteractionCompleterInput = HttpHandlerInput & InteractionCompleterParams;
/**
* Completes an IDP interaction, logging the user in.
*/
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> {
const provider = await this.providerFactory.getProvider();
const result: InteractionResults = {
login: {
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';
// Identity/Interaction/Email-Password/Handler
export * from './identity/interaction/email-password/handler/InteractionHandler';
export * from './identity/interaction/email-password/handler/ForgotPasswordHandler';
export * from './identity/interaction/email-password/handler/LoginHandler';
export * from './identity/interaction/email-password/handler/RegistrationHandler';
@ -39,13 +40,9 @@ export * from './identity/interaction/util/BaseEmailSender';
export * from './identity/interaction/util/EmailSender';
export * from './identity/interaction/util/FormDataUtil';
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';
// Identity/Interaction
export * from './identity/interaction/InteractionHttpHandler';
export * from './identity/interaction/SessionHttpHandler';
// Identity/Ownership

View File

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

View File

@ -87,12 +87,7 @@ export class IdentityTestState {
expect(nextUrl.startsWith(this.oidcIssuer)).toBeTruthy();
// Need to catch the redirect so we can copy the cookies
let 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);
const res = await this.fetchIdp(nextUrl);
expect(res.status).toBe(302);
nextUrl = res.headers.get('location')!;

View File

@ -1,92 +1,210 @@
import type { Provider } from 'oidc-provider';
import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory';
import { IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler';
import type { InteractionHttpHandler } from '../../../src/identity/interaction/InteractionHttpHandler';
import { InteractionRoute, IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler';
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 { ResponseDescription } from '../../../src/ldp/http/response/ResponseDescription';
import type { ResponseWriter } from '../../../src/ldp/http/ResponseWriter';
import type { HttpRequest } from '../../../src/server/HttpRequest';
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 => {
const request: HttpRequest = {} as any;
const idpPath = '/idp';
let request: HttpRequest;
const response: HttpResponse = {} as any;
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 responseWriter: jest.Mocked<ResponseWriter>;
let provider: jest.Mocked<Provider>;
let handler: IdentityProviderHttpHandler;
beforeEach(async(): Promise<void> => {
request = { url: '/idp', method: 'GET' } as any;
provider = {
callback: jest.fn(),
interactionDetails: jest.fn(),
} as any;
providerFactory = {
getProvider: jest.fn().mockResolvedValue(provider),
};
interactionHttpHandler = {
canHandle: jest.fn(),
handle: jest.fn(),
} as any;
const handlers: InteractionHandler[] = [
{ handleSafe: jest.fn().mockResolvedValue({ type: 'response', details: { key: 'val' }}) } as any,
{ handleSafe: jest.fn().mockResolvedValue({ type: 'complete', details: { webId: 'webId' }}) } 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;
responseWriter = { handleSafe: jest.fn() } as any;
handler = new IdentityProviderHttpHandler(
idpPath,
providerFactory,
interactionHttpHandler,
Object.values(routes),
templateHandler,
interactionCompleter,
errorHandler,
responseWriter,
);
});
it('calls the provider if there is no matching handler.', async(): Promise<void> => {
(interactionHttpHandler.canHandle as jest.Mock).mockRejectedValueOnce(new Error('error!'));
it('errors if the idpPath does not start with a slash.', async(): Promise<void> => {
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();
expect(provider.callback).toHaveBeenCalledTimes(1);
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();
expect(provider.callback).toHaveBeenCalledTimes(0);
expect(interactionHttpHandler.handle).toHaveBeenCalledTimes(1);
expect(interactionHttpHandler.handle).toHaveBeenLastCalledWith({ request, response, provider });
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(0);
expect(templateHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(templateHandler.handleSafe).toHaveBeenLastCalledWith(
{ response, templateFile: routes.response.viewTemplate, contents: { errorMessage: '', prefilled: {}}},
);
});
it('returns an error response if there was an issue with the interaction handler.', async(): Promise<void> => {
const error = new Error('error!');
const errorResponse: ResponseDescription = { statusCode: 500 };
interactionHttpHandler.handle.mockRejectedValueOnce(error);
errorHandler.handleSafe.mockResolvedValueOnce(errorResponse);
it('calls the templateHandler for InteractionResponseResults.', async(): Promise<void> => {
request.url = '/idp/routeResponse';
request.method = 'POST';
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
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();
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).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/plain': 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> => {
interactionHttpHandler.handle.mockRejectedValueOnce('apple!');
await expect(handler.handle({ request, response })).rejects.toEqual('apple!');
it('can only resolve GET/POST requests.', async(): Promise<void> => {
request.url = '/idp/routeResponse';
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> => {
const error = new Error('error!');
providerFactory.getProvider.mockRejectedValueOnce(error);
await expect(handler.handle({ request, response })).rejects.toThrow(error);
it('can only resolve InteractionResponseResult responses if a responseTemplate is set.', async(): Promise<void> => {
request.url = '/idp/routeResponse';
request.method = 'POST';
(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');
await expect(handler.handle({ request, response })).rejects.toBe('apple');
it('errors if no route is configured for the default prompt.', async(): Promise<void> => {
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 { ProviderFactory } from '../../../../src/identity/configuration/ProviderFactory';
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 { HttpResponse } from '../../../../src/server/HttpResponse';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
@ -11,7 +11,6 @@ describe('A SessionHttpHandler', (): void => {
const webId = 'http://test.com/id#me';
let details: any = {};
let provider: Provider;
let oidcInteractionCompleter: InteractionCompleter;
let handler: SessionHttpHandler;
beforeEach(async(): Promise<void> => {
@ -20,31 +19,27 @@ describe('A SessionHttpHandler', (): void => {
interactionDetails: jest.fn().mockResolvedValue(details),
} as any;
oidcInteractionCompleter = {
handleSafe: jest.fn(),
} as any;
const factory: ProviderFactory = {
getProvider: jest.fn().mockResolvedValue(provider),
};
handler = new SessionHttpHandler(oidcInteractionCompleter);
handler = new SessionHttpHandler(factory);
});
it('requires a session and accountId.', async(): Promise<void> => {
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 };
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> => {
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).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 {
ForgotPasswordHandler,
} from '../../../../../../src/identity/interaction/email-password/handler/ForgotPasswordHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender';
import type { IdpRenderHandler } from '../../../../../../src/identity/interaction/util/IdpRenderHandler';
import type { HttpRequest } from '../../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../../src/server/HttpResponse';
import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine';
@ -16,9 +14,6 @@ describe('A ForgotPasswordHandler', (): void => {
const email = 'test@test.email';
const recordId = '123456';
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;
const baseUrl = 'http://test.com/base/';
const idpPath = '/idp';
@ -29,10 +24,6 @@ describe('A ForgotPasswordHandler', (): void => {
beforeEach(async(): Promise<void> => {
request = createPostFormRequest({ email });
messageRenderHandler = {
handleSafe: jest.fn(),
} as any;
accountStore = {
generateForgotPasswordRecord: jest.fn().mockResolvedValue(recordId),
} as any;
@ -46,7 +37,6 @@ describe('A ForgotPasswordHandler', (): void => {
} as any;
handler = new ForgotPasswordHandler({
messageRenderHandler,
accountStore,
baseUrl,
idpPath,
@ -57,21 +47,21 @@ describe('A ForgotPasswordHandler', (): void => {
it('errors on non-string emails.', async(): Promise<void> => {
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' ]});
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> => {
(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(messageRenderHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(messageRenderHandler.handleSafe).toHaveBeenLastCalledWith(renderParams);
});
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).toHaveBeenLastCalledWith({
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}`,
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 type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { InteractionHttpHandlerInput } from '../../../../../../src/identity/interaction/InteractionHttpHandler';
import type { InteractionCompleter } from '../../../../../../src/identity/interaction/util/InteractionCompleter';
import type { HttpHandlerInput } from '../../../../../../src/server/HttpHandler';
import { createPostFormRequest } from './Util';
describe('A LoginHandler', (): void => {
const webId = 'http://alice.test.com/card#me';
const email = 'alice@test.email';
let input: InteractionHttpHandlerInput;
let input: HttpHandlerInput;
let storageAdapter: AccountStore;
let interactionCompleter: InteractionCompleter;
let handler: LoginHandler;
beforeEach(async(): Promise<void> => {
@ -19,11 +17,7 @@ describe('A LoginHandler', (): void => {
authenticate: jest.fn().mockResolvedValue(webId),
} as any;
interactionCompleter = {
handleSafe: jest.fn(),
} as any;
handler = new LoginHandler({ accountStore: storageAdapter, interactionCompleter });
handler = new LoginHandler(storageAdapter);
});
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 }}));
});
it('calls the OidcInteractionCompleter when done.', async(): Promise<void> => {
it('returns an InteractionCompleteResult when done.', async(): Promise<void> => {
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).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 { HttpRequest } from '../../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../../src/server/HttpResponse';
import type { TemplateHandler } from '../../../../../../src/server/util/TemplateHandler';
import { createPostFormRequest } from './Util';
describe('A RegistrationHandler', (): void => {
@ -37,7 +36,6 @@ describe('A RegistrationHandler', (): void => {
let ownershipValidator: OwnershipValidator;
let accountStore: AccountStore;
let podManager: PodManager;
let responseHandler: TemplateHandler<NodeJS.Dict<any>>;
let handler: RegistrationHandler;
beforeEach(async(): Promise<void> => {
@ -61,10 +59,6 @@ describe('A RegistrationHandler', (): void => {
createPod: jest.fn(),
};
responseHandler = {
handleSafe: jest.fn(),
} as any;
handler = new RegistrationHandler({
baseUrl,
webIdSuffix,
@ -72,7 +66,6 @@ describe('A RegistrationHandler', (): void => {
accountStore,
ownershipValidator,
podManager,
responseHandler,
});
});
@ -151,7 +144,17 @@ describe('A RegistrationHandler', (): void => {
describe('handling data', (): void => {
it('can register a user.', async(): Promise<void> => {
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).toHaveBeenLastCalledWith({ webId });
@ -168,7 +171,18 @@ describe('A RegistrationHandler', (): void => {
it('can create a pod.', async(): Promise<void> => {
const params = { email, webId, podName, createPod };
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).toHaveBeenLastCalledWith({ webId });
@ -186,7 +200,18 @@ describe('A RegistrationHandler', (): void => {
const params = { email, webId, password, confirmPassword, podName, register, createPod };
podSettings.oidcIssuer = baseUrl;
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).toHaveBeenLastCalledWith({ webId });
@ -225,12 +250,23 @@ describe('A RegistrationHandler', (): void => {
it('can create a WebID with an account and pod.', async(): Promise<void> => {
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);
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).toHaveBeenLastCalledWith(podName);

View File

@ -4,7 +4,6 @@ import {
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { HttpRequest } from '../../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../../src/server/HttpResponse';
import type { TemplateHandler } from '../../../../../../src/server/util/TemplateHandler';
import { createPostFormRequest } from './Util';
describe('A ResetPasswordHandler', (): void => {
@ -14,7 +13,6 @@ describe('A ResetPasswordHandler', (): void => {
const url = `/resetURL/${recordId}`;
const email = 'alice@test.email';
let accountStore: AccountStore;
let messageRenderHandler: TemplateHandler<{ message: string }>;
let handler: ResetPasswordHandler;
beforeEach(async(): Promise<void> => {
@ -24,14 +22,7 @@ describe('A ResetPasswordHandler', (): void => {
changePassword: jest.fn(),
} as any;
messageRenderHandler = {
handleSafe: jest.fn(),
} as any;
handler = new ResetPasswordHandler({
accountStore,
messageRenderHandler,
});
handler = new ResetPasswordHandler(accountStore);
});
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> => {
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).toHaveBeenLastCalledWith(recordId);
expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1);
expect(accountStore.deleteForgotPasswordRecord).toHaveBeenLastCalledWith(recordId);
expect(accountStore.changePassword).toHaveBeenCalledTimes(1);
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> => {

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

View File

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