diff --git a/config/identity/handler/account-store/default.json b/config/identity/handler/account-store/default.json new file mode 100644 index 000000000..01f59d4c0 --- /dev/null +++ b/config/identity/handler/account-store/default.json @@ -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" + } + } + ] +} diff --git a/config/identity/handler/default.json b/config/identity/handler/default.json index dcae01c4e..aa44e2bfa 100644 --- a/config/identity/handler/default.json +++ b/config/identity/handler/default.json @@ -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" } } diff --git a/config/identity/handler/interaction/handler.json b/config/identity/handler/interaction/handler.json deleted file mode 100644 index 02de864c8..000000000 --- a/config/identity/handler/interaction/handler.json +++ /dev/null @@ -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" - } - ] -} diff --git a/config/identity/handler/interaction/handlers/forgot-password.json b/config/identity/handler/interaction/handlers/forgot-password.json deleted file mode 100644 index cfcacc131..000000000 --- a/config/identity/handler/interaction/handlers/forgot-password.json +++ /dev/null @@ -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" - } - } - ] -} diff --git a/config/identity/handler/interaction/handlers/initial.json b/config/identity/handler/interaction/handlers/initial.json deleted file mode 100644 index 64ba556b3..000000000 --- a/config/identity/handler/interaction/handlers/initial.json +++ /dev/null @@ -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" - } - } - ] -} diff --git a/config/identity/handler/interaction/handlers/login.json b/config/identity/handler/interaction/handlers/login.json deleted file mode 100644 index de34b6cc5..000000000 --- a/config/identity/handler/interaction/handlers/login.json +++ /dev/null @@ -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" - } - } - ] -} diff --git a/config/identity/handler/interaction/handlers/reset-password.json b/config/identity/handler/interaction/handlers/reset-password.json deleted file mode 100644 index 4fb0059e0..000000000 --- a/config/identity/handler/interaction/handlers/reset-password.json +++ /dev/null @@ -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" - } - } - ] -} diff --git a/config/identity/handler/interaction/handlers/session.json b/config/identity/handler/interaction/handlers/session.json deleted file mode 100644 index 3efcc2584..000000000 --- a/config/identity/handler/interaction/handlers/session.json +++ /dev/null @@ -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" - } - } - ] -} diff --git a/config/identity/handler/interaction/routes.json b/config/identity/handler/interaction/routes.json new file mode 100644 index 000000000..edf4c366e --- /dev/null +++ b/config/identity/handler/interaction/routes.json @@ -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" } + ] + } + ] +} diff --git a/config/identity/handler/interaction/routes/forgot-password.json b/config/identity/handler/interaction/routes/forgot-password.json new file mode 100644 index 000000000..432f7e96d --- /dev/null +++ b/config/identity/handler/interaction/routes/forgot-password.json @@ -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" } + } + } + ] +} diff --git a/config/identity/handler/interaction/routes/login.json b/config/identity/handler/interaction/routes/login.json new file mode 100644 index 000000000..3eedbf37e --- /dev/null +++ b/config/identity/handler/interaction/routes/login.json @@ -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" } + } + } + ] +} diff --git a/config/identity/handler/interaction/routes/reset-password.json b/config/identity/handler/interaction/routes/reset-password.json new file mode 100644 index 000000000..29b4634ff --- /dev/null +++ b/config/identity/handler/interaction/routes/reset-password.json @@ -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" } + } + } + ] +} diff --git a/config/identity/handler/interaction/routes/session.json b/config/identity/handler/interaction/routes/session.json new file mode 100644 index 000000000..0bb261148 --- /dev/null +++ b/config/identity/handler/interaction/routes/session.json @@ -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" } + } + } + ] +} diff --git a/config/identity/registration/enabled.json b/config/identity/registration/enabled.json index 8c5194b3c..4fd3ca03d 100644 --- a/config/identity/registration/enabled.json +++ b/config/identity/registration/enabled.json @@ -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" } ] } ] diff --git a/config/identity/registration/handler/registration.json b/config/identity/registration/handler/registration.json deleted file mode 100644 index 5bf89125c..000000000 --- a/config/identity/registration/handler/registration.json +++ /dev/null @@ -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" - } - } - ] -} diff --git a/config/identity/registration/route/registration.json b/config/identity/registration/route/registration.json new file mode 100644 index 000000000..f4b8fec9e --- /dev/null +++ b/config/identity/registration/route/registration.json @@ -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" } + } + } + ] +} diff --git a/src/identity/IdentityProviderHttpHandler.ts b/src/identity/IdentityProviderHttpHandler.ts index 58668e112..a12539be3 100644 --- a/src/identity/IdentityProviderHttpHandler.ts +++ b/src/identity/IdentityProviderHttpHandler.ts @@ -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 { - 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 { 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 { + // 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 { + 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 { + 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): + Promise { + 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; + } } diff --git a/src/identity/interaction/InteractionHttpHandler.ts b/src/identity/interaction/InteractionHttpHandler.ts deleted file mode 100644 index a2751a238..000000000 --- a/src/identity/interaction/InteractionHttpHandler.ts +++ /dev/null @@ -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 {} diff --git a/src/identity/interaction/SessionHttpHandler.ts b/src/identity/interaction/SessionHttpHandler.ts index 18eb05526..f74ec00c8 100644 --- a/src/identity/interaction/SessionHttpHandler.ts +++ b/src/identity/interaction/SessionHttpHandler.ts @@ -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 { - const details = await input.provider.interactionDetails(input.request, input.response); + public async handle(input: HttpHandlerInput): Promise { + 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 }, + }; } } diff --git a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts index db20b87fb..c02e87886 100644 --- a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts @@ -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 { + public async handle(input: HttpHandlerInput): Promise> { 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 { - // Send response - await this.messageRenderHandler.handleSafe({ - response, - contents: { - errorMessage: '', - prefilled: { - email, - }, - }, - }); - } } diff --git a/src/identity/interaction/email-password/handler/InteractionHandler.ts b/src/identity/interaction/email-password/handler/InteractionHandler.ts new file mode 100644 index 000000000..4901ab9e1 --- /dev/null +++ b/src/identity/interaction/email-password/handler/InteractionHandler.ts @@ -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> { + type: 'response'; + details: T; +} + +export interface InteractionCompleteResult { + type: 'complete'; + details: InteractionCompleterParams; +} + +/** + * Handler used for IDP interactions. + */ +export abstract class InteractionHandler extends AsyncHandler {} diff --git a/src/identity/interaction/email-password/handler/LoginHandler.ts b/src/identity/interaction/email-password/handler/LoginHandler.ts index e0a4577ea..579542c07 100644 --- a/src/identity/interaction/email-password/handler/LoginHandler.ts +++ b/src/identity/interaction/email-password/handler/LoginHandler.ts @@ -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 { + public async handle(input: HttpHandlerInput): Promise { 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 }); } diff --git a/src/identity/interaction/email-password/handler/RegistrationHandler.ts b/src/identity/interaction/email-password/handler/RegistrationHandler.ts index 5199bf7aa..ded5dda54 100644 --- a/src/identity/interaction/email-password/handler/RegistrationHandler.ts +++ b/src/identity/interaction/email-password/handler/RegistrationHandler.ts @@ -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 { + public async handle({ request }: HttpHandlerInput): Promise> { 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; diff --git a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts index 9fdecc8d5..740e70932 100644 --- a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts @@ -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 { + public async handle(input: HttpHandlerInput): Promise { 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); } diff --git a/src/identity/interaction/util/IdpRenderHandler.ts b/src/identity/interaction/util/IdpRenderHandler.ts deleted file mode 100644 index 40a2cfe15..000000000 --- a/src/identity/interaction/util/IdpRenderHandler.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { TemplateHandler } from '../../../server/util/TemplateHandler'; - -export interface IdpRenderHandlerProps { - errorMessage?: string; - prefilled?: Record; -} - -/** - * 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 {} diff --git a/src/identity/interaction/util/IdpRouteController.ts b/src/identity/interaction/util/IdpRouteController.ts deleted file mode 100644 index d6ec99705..000000000 --- a/src/identity/interaction/util/IdpRouteController.ts +++ /dev/null @@ -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 { - return this.renderHandler.handleSafe({ - response: input.response, - contents: { errorMessage, prefilled }, - }); - } - - public async handle(input: InteractionHttpHandlerInput): Promise { - 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); - } - } - } -} diff --git a/src/identity/interaction/util/InitialInteractionHandler.ts b/src/identity/interaction/util/InitialInteractionHandler.ts deleted file mode 100644 index 2ca8dcac6..000000000 --- a/src/identity/interaction/util/InitialInteractionHandler.ts +++ /dev/null @@ -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 { - // 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(); - } -} diff --git a/src/identity/interaction/util/InteractionCompleter.ts b/src/identity/interaction/util/InteractionCompleter.ts index 1bf43fd71..121d68590 100644 --- a/src/identity/interaction/util/InteractionCompleter.ts +++ b/src/identity/interaction/util/InteractionCompleter.ts @@ -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 { + private readonly providerFactory: ProviderFactory; + + public constructor(providerFactory: ProviderFactory) { + super(); + this.providerFactory = providerFactory; + } + public async handle(input: InteractionCompleterInput): Promise { + const provider = await this.providerFactory.getProvider(); const result: InteractionResults = { login: { account: input.webId, @@ -23,6 +34,6 @@ export class InteractionCompleter extends AsyncHandler = Dict> - 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 = Dict> this.contentType = contentType; } - public async handle({ response, contents }: { response: HttpResponse; contents: T }): Promise { - const rendered = await this.templateEngine.render(contents); + public async handle({ response, templateFile, contents }: + { response: HttpResponse; templateFile: string; contents: T }): Promise { + 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); diff --git a/templates/identity/email-password/email-sent.html.ejs b/templates/identity/email-password/email-sent.html.ejs index 748f6e743..e04f86b50 100644 --- a/templates/identity/email-password/email-sent.html.ejs +++ b/templates/identity/email-password/email-sent.html.ejs @@ -17,7 +17,7 @@

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

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

- +

Back to Log In

diff --git a/test/integration/IdentityTestState.ts b/test/integration/IdentityTestState.ts index 5ff326830..167c849a1 100644 --- a/test/integration/IdentityTestState.ts +++ b/test/integration/IdentityTestState.ts @@ -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')!; diff --git a/test/unit/identity/IdentityProviderHttpHandler.test.ts b/test/unit/identity/IdentityProviderHttpHandler.test.ts index ee3d063eb..3f085fec3 100644 --- a/test/unit/identity/IdentityProviderHttpHandler.test.ts +++ b/test/unit/identity/IdentityProviderHttpHandler.test.ts @@ -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; - let interactionHttpHandler: jest.Mocked; + let routes: { response: InteractionRoute; complete: InteractionRoute }; + let interactionCompleter: jest.Mocked; + let templateHandler: jest.Mocked; let errorHandler: jest.Mocked; let responseWriter: jest.Mocked; let provider: jest.Mocked; let handler: IdentityProviderHttpHandler; beforeEach(async(): Promise => { + 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 => { - (interactionHttpHandler.canHandle as jest.Mock).mockRejectedValueOnce(new Error('error!')); + it('errors if the idpPath does not start with a slash.', async(): Promise => { + 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 => { + 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 => { + it('calls the templateHandler for matching GET requests.', async(): Promise => { + 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 => { - 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { - interactionHttpHandler.handle.mockRejectedValueOnce('apple!'); - await expect(handler.handle({ request, response })).rejects.toEqual('apple!'); + it('can only resolve GET/POST requests.', async(): Promise => { + 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 => { - 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 => { + 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 => { + 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 }}); }); }); diff --git a/test/unit/identity/interaction/SessionHttpHandler.test.ts b/test/unit/identity/interaction/SessionHttpHandler.test.ts index 6ca588943..fe3311a68 100644 --- a/test/unit/identity/interaction/SessionHttpHandler.test.ts +++ b/test/unit/identity/interaction/SessionHttpHandler.test.ts @@ -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 => { @@ -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 => { 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 => { - 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, - }); }); }); diff --git a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts index f62549014..4cac62f59 100644 --- a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts @@ -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 = `Reset Password`; - 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 => { 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 => { 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 => { (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 => { - 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); }); }); diff --git a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts index acf8523f5..f4ab906ed 100644 --- a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts @@ -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 => { @@ -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 => { @@ -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 => { + it('returns an InteractionCompleteResult when done.', async(): Promise => { 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 }); }); }); diff --git a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts index 444b1122d..8524aae3d 100644 --- a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts @@ -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>; let handler: RegistrationHandler; beforeEach(async(): Promise => { @@ -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 => { 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 => { 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 => { 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); diff --git a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts index 19cd6ca6b..fd6b3d413 100644 --- a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts @@ -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 => { @@ -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 => { @@ -57,16 +48,16 @@ describe('A ResetPasswordHandler', (): void => { it('renders a message on success.', async(): Promise => { 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 => { diff --git a/test/unit/identity/interaction/util/IdpRouteController.test.ts b/test/unit/identity/interaction/util/IdpRouteController.test.ts deleted file mode 100644 index 31b619345..000000000 --- a/test/unit/identity/interaction/util/IdpRouteController.test.ts +++ /dev/null @@ -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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - request.method = 'DELETE'; - await expect(controller.handle({ request, response, provider })).resolves.toBeUndefined(); - expect(postHandler.handleSafe).toHaveBeenCalledTimes(0); - expect(renderHandler.handleSafe).toHaveBeenCalledTimes(0); - }); -}); diff --git a/test/unit/identity/interaction/util/InitialInteractionHandler.test.ts b/test/unit/identity/interaction/util/InitialInteractionHandler.test.ts deleted file mode 100644 index eaacf4e4c..000000000 --- a/test/unit/identity/interaction/util/InitialInteractionHandler.test.ts +++ /dev/null @@ -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; - let provider: jest.Mocked; - // `Interaction` type is not exposed - let details: any; - let map: RedirectMap; - let handler: InitialInteractionHandler; - - beforeEach(async(): Promise => { - 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 => { - 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 => { - 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); - }); -}); diff --git a/test/unit/identity/interaction/util/InteractionCompleter.test.ts b/test/unit/identity/interaction/util/InteractionCompleter.test.ts index aed4b6125..910ec0b11 100644 --- a/test/unit/identity/interaction/util/InteractionCompleter.test.ts +++ b/test/unit/identity/interaction/util/InteractionCompleter.test.ts @@ -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 => { 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 => { - 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 => { - 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, { diff --git a/test/unit/server/util/TemplateHandler.test.ts b/test/unit/server/util/TemplateHandler.test.ts index dcc1b3f3d..b920273fd 100644 --- a/test/unit/server/util/TemplateHandler.test.ts +++ b/test/unit/server/util/TemplateHandler.test.ts @@ -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; let response: HttpResponse; @@ -17,10 +18,10 @@ describe('A TemplateHandler', (): void => { it('renders the template in the response.', async(): Promise => { 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);