From bc0eeb1012e15e9e9ee0f9085be209f6a9229ccd Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Thu, 2 Dec 2021 09:57:23 +0100 Subject: [PATCH] feat: Split up IDP HTML, routing, and handler behaviour --- config/identity/handler/default.json | 4 +- .../identity/handler/interaction/routes.json | 40 +++- .../interaction/routes/existing-login.json | 16 ++ .../interaction/routes/forgot-password.json | 26 +-- .../handler/interaction/routes/login.json | 18 +- .../handler/interaction/routes/prompt.json | 25 +++ .../interaction/routes/reset-password.json | 18 +- .../handler/interaction/routes/session.json | 20 -- .../handler/interaction/views/controls.json | 19 ++ .../handler/interaction/views/html.json | 43 ++++ config/identity/registration/enabled.json | 30 ++- .../registration/route/registration.json | 21 +- src/identity/IdentityProviderHttpHandler.ts | 186 +++--------------- .../configuration/IdentityProviderFactory.ts | 42 ++-- .../interaction/BaseInteractionHandler.ts | 51 +++++ .../CompletingInteractionHandler.ts | 15 +- src/identity/interaction/ControlHandler.ts | 43 ++++ ...HttpHandler.ts => ExistingLoginHandler.ts} | 6 +- src/identity/interaction/HtmlViewHandler.ts | 54 +++++ .../interaction/InteractionHandler.ts | 20 +- src/identity/interaction/PromptHandler.ts | 28 +++ .../handler/ForgotPasswordHandler.ts | 35 ++-- .../email-password/handler/LoginHandler.ts | 27 ++- .../handler/RegistrationHandler.ts | 35 +++- .../handler/ResetPasswordHandler.ts | 27 ++- .../routing/BasicInteractionRoute.ts | 118 +++-------- .../interaction/routing/InteractionRoute.ts | 30 +-- .../routing/RelativeInteractionRoute.ts | 18 ++ src/index.ts | 7 +- .../{confirm.html.ejs => consent.html.ejs} | 0 test/unit/identity/ControlHandler.test.ts | 52 +++++ .../IdentityProviderHttpHandler.test.ts | 159 ++++----------- .../IdentityProviderFactory.test.ts | 56 ++++-- .../BaseInteractionHandler.test.ts | 70 +++++++ .../CompletingInteractionHandler.test.ts | 21 +- ...r.test.ts => ExistingLoginHandler.test.ts} | 29 +-- .../interaction/HtmlViewHandler.test.ts | 68 +++++++ .../interaction/InteractionHandler.test.ts | 13 +- .../interaction/PromptHandler.test.ts | 37 ++++ .../handler/ForgotPasswordHandler.test.ts | 23 ++- .../handler/LoginHandler.test.ts | 35 ++-- .../handler/RegistrationHandler.test.ts | 8 +- .../handler/ResetPasswordHandler.test.ts | 5 +- .../routing/BasicInteractionRoute.test.ts | 101 ++++------ .../routing/RelativeInteractionRoute.test.ts | 30 +++ 45 files changed, 1013 insertions(+), 716 deletions(-) create mode 100644 config/identity/handler/interaction/routes/existing-login.json create mode 100644 config/identity/handler/interaction/routes/prompt.json delete mode 100644 config/identity/handler/interaction/routes/session.json create mode 100644 config/identity/handler/interaction/views/controls.json create mode 100644 config/identity/handler/interaction/views/html.json create mode 100644 src/identity/interaction/BaseInteractionHandler.ts create mode 100644 src/identity/interaction/ControlHandler.ts rename src/identity/interaction/{SessionHttpHandler.ts => ExistingLoginHandler.ts} (83%) create mode 100644 src/identity/interaction/HtmlViewHandler.ts create mode 100644 src/identity/interaction/PromptHandler.ts create mode 100644 src/identity/interaction/routing/RelativeInteractionRoute.ts rename templates/identity/email-password/{confirm.html.ejs => consent.html.ejs} (100%) create mode 100644 test/unit/identity/ControlHandler.test.ts create mode 100644 test/unit/identity/interaction/BaseInteractionHandler.test.ts rename test/unit/identity/interaction/{SessionHttpHandler.test.ts => ExistingLoginHandler.test.ts} (50%) create mode 100644 test/unit/identity/interaction/HtmlViewHandler.test.ts create mode 100644 test/unit/identity/interaction/PromptHandler.test.ts create mode 100644 test/unit/identity/interaction/routing/RelativeInteractionRoute.test.ts diff --git a/config/identity/handler/default.json b/config/identity/handler/default.json index 50fe0f71b..fd57f21cd 100644 --- a/config/identity/handler/default.json +++ b/config/identity/handler/default.json @@ -39,11 +39,9 @@ "comment": "Handles IDP handler behaviour.", "@id": "urn:solid-server:default:IdentityProviderHttpHandler", "@type": "IdentityProviderHttpHandler", - "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "args_idpPath": "/idp", "args_providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }, "args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, - "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" } + "args_handler": { "@id": "urn:solid-server:default:InteractionHandler" } } ] } diff --git a/config/identity/handler/interaction/routes.json b/config/identity/handler/interaction/routes.json index 83be9a153..034571015 100644 --- a/config/identity/handler/interaction/routes.json +++ b/config/identity/handler/interaction/routes.json @@ -1,19 +1,45 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", "import": [ + "files-scs:config/identity/handler/interaction/routes/existing-login.json", "files-scs:config/identity/handler/interaction/routes/forgot-password.json", "files-scs:config/identity/handler/interaction/routes/login.json", + "files-scs:config/identity/handler/interaction/routes/prompt.json", "files-scs:config/identity/handler/interaction/routes/reset-password.json", - "files-scs:config/identity/handler/interaction/routes/session.json" + "files-scs:config/identity/handler/interaction/views/controls.json", + "files-scs:config/identity/handler/interaction/views/html.json" ], "@graph": [ { - "@id": "urn:solid-server:default:IdentityProviderHttpHandler", - "IdentityProviderHttpHandler:_args_interactionRoutes": [ - { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" }, - { "@id": "urn:solid-server:auth:password:LoginRoute" }, - { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" }, - { "@id": "urn:solid-server:auth:password:SessionRoute" } + "@id": "urn:solid-server:default:InteractionHandler", + "@type": "WaterfallHandler", + "handlers": [ + { + "comment": "Returns the relevant HTML pages for the interactions when needed", + "@id": "urn:solid-server:auth:password:HtmlViewHandler" + }, + { + "comment": "Adds controls and API version to JSON responses.", + "@id": "urn:solid-server:auth:password:ControlHandler", + "ControlHandler:_source" : { + "@id": "urn:solid-server:auth:password:RouteInteractionHandler", + "@type": "WaterfallHandler", + "handlers": [ + { + "comment": [ + "This handler is required to prevent Components.js issues with arrays.", + "This might be fixed in the next Components.js release after which this can be removed." + ], + "@type": "UnsupportedAsyncHandler" + }, + { "@id": "urn:solid-server:auth:password:PromptRoute" }, + { "@id": "urn:solid-server:auth:password:LoginRoute" }, + { "@id": "urn:solid-server:auth:password:ExistingLoginRoute" }, + { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" }, + { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" } + ] + } + } ] } ] diff --git a/config/identity/handler/interaction/routes/existing-login.json b/config/identity/handler/interaction/routes/existing-login.json new file mode 100644 index 000000000..cb2baaf53 --- /dev/null +++ b/config/identity/handler/interaction/routes/existing-login.json @@ -0,0 +1,16 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles the interaction that occurs when a logged in user wants to authenticate with a new app.", + "@id": "urn:solid-server:auth:password:ExistingLoginRoute", + "@type": "RelativeInteractionRoute", + "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "relativePath": "/idp/consent/", + "source": { + "@type": "ExistingLoginHandler", + "interactionCompleter": { "@type": "BaseInteractionCompleter" } + } + } + ] +} diff --git a/config/identity/handler/interaction/routes/forgot-password.json b/config/identity/handler/interaction/routes/forgot-password.json index 3fe3ca6c5..946736770 100644 --- a/config/identity/handler/interaction/routes/forgot-password.json +++ b/config/identity/handler/interaction/routes/forgot-password.json @@ -2,32 +2,20 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", "@graph": [ { - "comment": "Handles all functionality on the forgot password page", + "comment": "Handles the forgot password interaction", "@id": "urn:solid-server:auth:password:ForgotPasswordRoute", - "@type": "BasicInteractionRoute", - "route": "^/forgotpassword/$", - "viewTemplates": { - "BasicInteractionRoute:_viewTemplates_key": "text/html", - "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/forgot-password.html.ejs" - }, - "responseTemplates": { - "BasicInteractionRoute:_responseTemplates_key": "text/html", - "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/forgot-password-response.html.ejs" - }, - "controls": { - "BasicInteractionRoute:_controls_key": "forgotPassword", - "BasicInteractionRoute:_controls_value": "/forgotpassword/" - }, - "handler": { + "@type": "RelativeInteractionRoute", + "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "relativePath": "/idp/forgotpassword/", + "source": { "@type": "ForgotPasswordHandler", "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, - "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "args_idpPath": "/idp", "args_templateEngine": { "@type": "EjsTemplateEngine", "template": "@css:templates/identity/email-password/reset-password-email.html.ejs" }, - "args_emailSender": { "@id": "urn:solid-server:default:EmailSender" } + "args_emailSender": { "@id": "urn:solid-server:default:EmailSender" }, + "args_resetRoute": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" } } } ] diff --git a/config/identity/handler/interaction/routes/login.json b/config/identity/handler/interaction/routes/login.json index 66f48dffd..bcd00ac67 100644 --- a/config/identity/handler/interaction/routes/login.json +++ b/config/identity/handler/interaction/routes/login.json @@ -2,20 +2,12 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", "@graph": [ { - "comment": "Handles all functionality on the Login Page", + "comment": "Handles the login interaction", "@id": "urn:solid-server:auth:password:LoginRoute", - "@type": "BasicInteractionRoute", - "route": "^/login/$", - "prompt": "login", - "viewTemplates": { - "BasicInteractionRoute:_viewTemplates_key": "text/html", - "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/login.html.ejs" - }, - "controls": { - "BasicInteractionRoute:_controls_key": "login", - "BasicInteractionRoute:_controls_value": "/login/" - }, - "handler": { + "@type": "RelativeInteractionRoute", + "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "relativePath": "/idp/login/", + "source": { "@type": "LoginHandler", "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, "interactionCompleter": { "@type": "BaseInteractionCompleter" } diff --git a/config/identity/handler/interaction/routes/prompt.json b/config/identity/handler/interaction/routes/prompt.json new file mode 100644 index 000000000..9e77dbfef --- /dev/null +++ b/config/identity/handler/interaction/routes/prompt.json @@ -0,0 +1,25 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles OIDC redirects containing a prompt, such as login or consent.", + "@id": "urn:solid-server:auth:password:PromptRoute", + "@type": "RelativeInteractionRoute", + "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "relativePath": "/idp/", + "source": { + "@type": "PromptHandler", + "promptRoutes": [ + { + "PromptHandler:_promptRoutes_key": "login", + "PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:auth:password:LoginRoute" } + }, + { + "PromptHandler:_promptRoutes_key": "consent", + "PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:auth:password:ExistingLoginRoute" } + } + ] + } + } + ] +} diff --git a/config/identity/handler/interaction/routes/reset-password.json b/config/identity/handler/interaction/routes/reset-password.json index 9a48ad540..a64a53c2e 100644 --- a/config/identity/handler/interaction/routes/reset-password.json +++ b/config/identity/handler/interaction/routes/reset-password.json @@ -1,21 +1,13 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", - "comment": "Exports 2 handlers: one for viewing the page and one for doing the reset.", "@graph": [ { - "comment": "Handles the reset password page submission", + "comment": "Handles the reset password interaction", "@id": "urn:solid-server:auth:password:ResetPasswordRoute", - "@type": "BasicInteractionRoute", - "route": "^/resetpassword/$", - "viewTemplates": { - "BasicInteractionRoute:_viewTemplates_key": "text/html", - "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/reset-password.html.ejs" - }, - "responseTemplates": { - "BasicInteractionRoute:_responseTemplates_key": "text/html", - "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/reset-password-response.html.ejs" - }, - "handler": { + "@type": "RelativeInteractionRoute", + "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "relativePath": "/idp/resetpassword/", + "source": { "@type": "ResetPasswordHandler", "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" } } diff --git a/config/identity/handler/interaction/routes/session.json b/config/identity/handler/interaction/routes/session.json deleted file mode 100644 index 90910688c..000000000 --- a/config/identity/handler/interaction/routes/session.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", - "@graph": [ - { - "comment": "Handles confirm requests", - "@id": "urn:solid-server:auth:password:SessionRoute", - "@type": "BasicInteractionRoute", - "route": "^/confirm/$", - "prompt": "consent", - "viewTemplates": { - "BasicInteractionRoute:_viewTemplates_key": "text/html", - "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/confirm.html.ejs" - }, - "handler": { - "@type": "SessionHttpHandler", - "interactionCompleter": { "@type": "BaseInteractionCompleter" } - } - } - ] -} diff --git a/config/identity/handler/interaction/views/controls.json b/config/identity/handler/interaction/views/controls.json new file mode 100644 index 000000000..50f11c451 --- /dev/null +++ b/config/identity/handler/interaction/views/controls.json @@ -0,0 +1,19 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@graph": [ + { + "@id": "urn:solid-server:auth:password:ControlHandler", + "@type": "ControlHandler", + "controls": [ + { + "ControlHandler:_controls_key": "login", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:LoginRoute" } + }, + { + "ControlHandler:_controls_key": "forgotPassword", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" } + } + ] + } + ] +} diff --git a/config/identity/handler/interaction/views/html.json b/config/identity/handler/interaction/views/html.json new file mode 100644 index 000000000..1ef478b55 --- /dev/null +++ b/config/identity/handler/interaction/views/html.json @@ -0,0 +1,43 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", + "@graph": [ + { + "@id": "urn:solid-server:auth:password:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templateEngine": { + "comment": "Renders the specific page and embeds it into the main HTML body.", + "@type": "ChainedTemplateEngine", + "renderedName": "htmlBody", + "engines": [ + { + "comment": "Will be called with specific templates to generate HTML snippets.", + "@type": "EjsTemplateEngine" + }, + { + "comment": "Will embed the result of the first engine into the main HTML template.", + "@type": "EjsTemplateEngine", + "template": "@css:templates/main.html.ejs" + } + ] + }, + "templates": [ + { + "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/login.html.ejs", + "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:LoginRoute" } + }, + { + "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/consent.html.ejs", + "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ExistingLoginRoute" } + }, + { + "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/forgot-password.html.ejs", + "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" } + }, + { + "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/reset-password.html.ejs", + "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" } + } + ] + } + ] +} diff --git a/config/identity/registration/enabled.json b/config/identity/registration/enabled.json index 7ff5663b9..5bebf8d2b 100644 --- a/config/identity/registration/enabled.json +++ b/config/identity/registration/enabled.json @@ -5,11 +5,35 @@ ], "@graph": [ { - "comment": "Enable registration by adding a registration handler to the list of interaction routes.", - "@id": "urn:solid-server:default:IdentityProviderHttpHandler", - "IdentityProviderHttpHandler:_args_interactionRoutes": [ + "@id": "urn:solid-server:auth:password:RouteInteractionHandler", + "WaterfallHandler:_handlers": [ + { + "comment": [ + "This handler is required to prevent Components.js issues with arrays.", + "This might be fixed in the next Components.js release after which this can be removed." + ], + "@type": "UnsupportedAsyncHandler" + }, { "@id": "urn:solid-server:auth:password:RegistrationRoute" } ] + }, + { + "@id": "urn:solid-server:auth:password:ControlHandler", + "ControlHandler:_controls": [ + { + "ControlHandler:_controls_key": "register", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:RegistrationRoute" } + } + ] + }, + { + "@id": "urn:solid-server:auth:password:HtmlViewHandler", + "HtmlViewHandler:_templates": [ + { + "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/register.html.ejs", + "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:RegistrationRoute" } + } + ] } ] } diff --git a/config/identity/registration/route/registration.json b/config/identity/registration/route/registration.json index c0aff91af..37e18edd4 100644 --- a/config/identity/registration/route/registration.json +++ b/config/identity/registration/route/registration.json @@ -2,23 +2,12 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld", "@graph": [ { - "comment": "Handles all functionality on the register page", + "comment": "Handles the register interaction", "@id": "urn:solid-server:auth:password:RegistrationRoute", - "@type": "BasicInteractionRoute", - "route": "^/register/$", - "viewTemplates": { - "BasicInteractionRoute:_viewTemplates_key": "text/html", - "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/register.html.ejs" - }, - "responseTemplates": { - "BasicInteractionRoute:_responseTemplates_key": "text/html", - "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/register-response.html.ejs" - }, - "controls": { - "BasicInteractionRoute:_controls_key": "register", - "BasicInteractionRoute:_controls_value": "/register/" - }, - "handler": { + "@type": "RelativeInteractionRoute", + "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "relativePath": "/idp/register/", + "source": { "@type": "RegistrationHandler", "registrationManager": { "@type": "RegistrationManager", diff --git a/src/identity/IdentityProviderHttpHandler.ts b/src/identity/IdentityProviderHttpHandler.ts index 174d5e68f..aa9f146ef 100644 --- a/src/identity/IdentityProviderHttpHandler.ts +++ b/src/identity/IdentityProviderHttpHandler.ts @@ -1,211 +1,81 @@ -import type { Operation } from '../http/Operation'; -import type { ErrorHandler } from '../http/output/error/ErrorHandler'; -import { ResponseDescription } from '../http/output/response/ResponseDescription'; -import { BasicRepresentation } from '../http/representation/BasicRepresentation'; +import { OkResponseDescription } from '../http/output/response/OkResponseDescription'; +import type { ResponseDescription } from '../http/output/response/ResponseDescription'; import { getLoggerFor } from '../logging/LogUtil'; import type { OperationHttpHandlerInput } from '../server/OperationHttpHandler'; import { OperationHttpHandler } from '../server/OperationHttpHandler'; import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter'; import { APPLICATION_JSON } from '../util/ContentTypes'; -import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; -import { joinUrl, trimTrailingSlashes } from '../util/PathUtil'; -import { addTemplateMetadata, cloneRepresentation } from '../util/ResourceUtil'; -import { readJsonStream } from '../util/StreamUtil'; import type { ProviderFactory } from './configuration/ProviderFactory'; -import type { Interaction } from './interaction/InteractionHandler'; -import type { InteractionRoute, TemplatedInteractionResult } from './interaction/routing/InteractionRoute'; - -const API_VERSION = '0.2'; +import type { + InteractionHandler, + Interaction, +} from './interaction/InteractionHandler'; export interface IdentityProviderHttpHandlerArgs { - /** - * Base URL of the server. - */ - baseUrl: string; - /** - * Relative path of the IDP entry point. - */ - idpPath: string; /** * Used to generate the OIDC provider. */ providerFactory: ProviderFactory; /** - * All routes handling the custom IDP behaviour. - */ - interactionRoutes: InteractionRoute[]; - /** - * Used for content negotiation. + * Used for converting the input data. */ converter: RepresentationConverter; /** - * Used for converting output errors. + * Handles the requests. */ - errorHandler: ErrorHandler; + handler: InteractionHandler; } /** - * Handles all requests relevant for the entire IDP interaction, - * by sending them to either a matching {@link InteractionRoute}, - * or the generated Provider from the {@link ProviderFactory} if there is no match. + * Generates the active Interaction object if there is an ongoing OIDC interaction + * and sends it to the {@link InteractionHandler}. * - * The InteractionRoutes handle all requests where we need custom behaviour, - * such as everything related to generating and validating an account. - * The Provider handles all the default request such as the initial handshake. + * Input data will first be converted to JSON. * - * This handler handles all requests since it assumes all those requests are relevant for the IDP interaction. - * A {@link RouterHandler} should be used to filter out other requests. + * Only GET and POST methods are accepted. */ export class IdentityProviderHttpHandler extends OperationHttpHandler { protected readonly logger = getLoggerFor(this); - private readonly baseUrl: string; private readonly providerFactory: ProviderFactory; - private readonly interactionRoutes: InteractionRoute[]; private readonly converter: RepresentationConverter; - private readonly errorHandler: ErrorHandler; - - private readonly controls: Record; + private readonly handler: InteractionHandler; public constructor(args: IdentityProviderHttpHandlerArgs) { super(); - // Trimming trailing slashes so the relative URL starts with a slash after slicing this off - this.baseUrl = trimTrailingSlashes(joinUrl(args.baseUrl, args.idpPath)); this.providerFactory = args.providerFactory; - this.interactionRoutes = args.interactionRoutes; this.converter = args.converter; - this.errorHandler = args.errorHandler; - - this.controls = Object.assign( - {}, - ...this.interactionRoutes.map((route): Record => this.getRouteControls(route)), - ); + this.handler = args.handler; } - /** - * Finds the matching route and resolves the operation. - */ public async handle({ operation, request, response }: OperationHttpHandlerInput): Promise { // This being defined means we're in an OIDC session let oidcInteraction: Interaction | undefined; try { const provider = await this.providerFactory.getProvider(); oidcInteraction = await provider.interactionDetails(request, response); + this.logger.debug('Found an active OIDC interaction.'); } catch { - // Just a regular request + this.logger.debug('No active OIDC interaction found.'); } - const route = await this.findRoute(operation, oidcInteraction); - if (!route) { - throw new NotFoundHttpError(); - } - - // Cloning input data so it can be sent back in case of errors - let clone = operation.body; - - // IDP handlers expect JSON data - if (!operation.body.isEmpty) { + // Convert input data to JSON + // Allows us to still support form data + const { contentType } = operation.body.metadata; + if (contentType && contentType !== APPLICATION_JSON) { + this.logger.debug(`Converting input ${contentType} to ${APPLICATION_JSON}`); const args = { representation: operation.body, preferences: { type: { [APPLICATION_JSON]: 1 }}, identifier: operation.target, }; - operation.body = await this.converter.handleSafe(args); - clone = await cloneRepresentation(operation.body); + operation = { + ...operation, + body: await this.converter.handleSafe(args), + }; } - const result = await route.handleOperation(operation, oidcInteraction); - - // Reset the body so it can be reused when needed for output - operation.body = clone; - - return this.handleInteractionResult(operation, result, oidcInteraction); - } - - /** - * Finds a route that supports the given request. - */ - private async findRoute(operation: Operation, oidcInteraction?: Interaction): Promise { - if (!operation.target.path.startsWith(this.baseUrl)) { - // This is an invalid request - return; - } - const pathName = operation.target.path.slice(this.baseUrl.length); - - for (const route of this.interactionRoutes) { - if (route.supportsPath(pathName, oidcInteraction?.prompt.name)) { - return route; - } - } - } - - /** - * Creates a ResponseDescription based on the InteractionHandlerResult. - * This will either be a redirect if type is "complete" or a data stream if the type is "response". - */ - private async handleInteractionResult(operation: Operation, result: TemplatedInteractionResult, - oidcInteraction?: Interaction): Promise { - let responseDescription: ResponseDescription | undefined; - - if (result.type === 'error') { - // We want to show the errors on the original page in case of html interactions, so we can't just throw them here - const preferences = { type: { [APPLICATION_JSON]: 1 }}; - const response = await this.errorHandler.handleSafe({ error: result.error, preferences }); - const details = await readJsonStream(response.data!); - - // Add the input data to the JSON response; - if (!operation.body.isEmpty) { - details.prefilled = await readJsonStream(operation.body.data); - - // Don't send passwords back - delete details.prefilled.password; - delete details.prefilled.confirmPassword; - } - - responseDescription = - await this.handleResponseResult(details, operation, result.templateFiles, oidcInteraction, response.statusCode); - } else { - // Convert the response object to a data stream - responseDescription = - await this.handleResponseResult(result.details ?? {}, operation, result.templateFiles, oidcInteraction); - } - - return responseDescription; - } - - /** - * Converts an InteractionResponseResult to a ResponseDescription by first converting to a Representation - * and applying necessary conversions. - */ - private async handleResponseResult(details: Record, operation: Operation, - templateFiles: Record, oidcInteraction?: Interaction, statusCode = 200): - Promise { - const json = { - ...details, - apiVersion: API_VERSION, - authenticating: Boolean(oidcInteraction), - controls: this.controls, - }; - const representation = new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON); - - // Template metadata is required for conversion - for (const [ type, templateFile ] of Object.entries(templateFiles)) { - addTemplateMetadata(representation.metadata, templateFile, type); - } - - // Potentially convert the Representation based on the preferences - const args = { representation, preferences: operation.preferences, identifier: operation.target }; - const converted = await this.converter.handleSafe(args); - - return new ResponseDescription(statusCode, converted.metadata, converted.data); - } - - /** - * Converts the controls object of a route to one with full URLs. - */ - private getRouteControls(route: InteractionRoute): Record { - const entries = Object.entries(route.getControls()) - .map(([ name, path ]): [ string, string ] => [ name, joinUrl(this.baseUrl, path) ]); - return Object.fromEntries(entries); + const representation = await this.handler.handleSafe({ operation, oidcInteraction }); + return new OkResponseDescription(representation.metadata, representation.data); } } diff --git a/src/identity/configuration/IdentityProviderFactory.ts b/src/identity/configuration/IdentityProviderFactory.ts index 4717c8dae..fc3125422 100644 --- a/src/identity/configuration/IdentityProviderFactory.ts +++ b/src/identity/configuration/IdentityProviderFactory.ts @@ -12,10 +12,15 @@ import type { AnyObject, ErrorOut, Adapter } from 'oidc-provider'; import { Provider } from 'oidc-provider'; +import type { Operation } from '../../http/Operation'; import type { ErrorHandler } from '../../http/output/error/ErrorHandler'; import type { ResponseWriter } from '../../http/output/ResponseWriter'; +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage'; -import { ensureTrailingSlash, joinUrl } from '../../util/PathUtil'; +import { InternalServerError } from '../../util/errors/InternalServerError'; +import { RedirectHttpError } from '../../util/errors/RedirectHttpError'; +import { joinUrl } from '../../util/PathUtil'; +import type { InteractionHandler } from '../interaction/InteractionHandler'; import type { AdapterFactory } from '../storage/AdapterFactory'; import type { ProviderFactory } from './ProviderFactory'; @@ -33,10 +38,9 @@ export interface IdentityProviderFactoryArgs { */ oidcPath: string; /** - * The entry point for the custom IDP handlers of the server. - * Should start with a slash. + * The handler responsible for redirecting interaction requests to the correct URL. */ - idpPath: string; + interactionHandler: InteractionHandler; /** * Storage used to store cookie and JWT keys so they can be re-used in case of multithreading. */ @@ -59,14 +63,14 @@ const COOKIES_KEY = 'cookie-secret'; * The provider will be cached and returned on subsequent calls. * Cookie and JWT keys will be stored in an internal storage so they can be re-used over multiple threads. * Necessary claims for Solid OIDC interactions will be added. - * Routes will be updated based on the `baseUrl` and `idpPath`. + * Routes will be updated based on the `baseUrl` and `oidcPath`. */ export class IdentityProviderFactory implements ProviderFactory { private readonly config: Configuration; private readonly adapterFactory!: AdapterFactory; private readonly baseUrl!: string; private readonly oidcPath!: string; - private readonly idpPath!: string; + private readonly interactionHandler!: InteractionHandler; private readonly storage!: KeyValueStorage; private readonly errorHandler!: ErrorHandler; private readonly responseWriter!: ResponseWriter; @@ -78,9 +82,6 @@ export class IdentityProviderFactory implements ProviderFactory { * @param args - Remaining parameters required for the factory. */ public constructor(config: Configuration, args: IdentityProviderFactoryArgs) { - if (!args.idpPath.startsWith('/')) { - throw new Error('idpPath needs to start with a /'); - } this.config = config; Object.assign(this, args); } @@ -230,7 +231,26 @@ export class IdentityProviderFactory implements ProviderFactory { // (missing user session, requested ACR not fulfilled, prompt requested, ...) // it will resolve the interactions.url helper function and redirect the User-Agent to that url. config.interactions = { - url: (): string => ensureTrailingSlash(this.idpPath), + url: async(ctx, oidcInteraction): Promise => { + const operation: Operation = { + method: ctx.method, + target: { path: ctx.request.href }, + preferences: {}, + body: new BasicRepresentation(), + }; + + // Instead of sending a 3xx redirect to the client (via a RedirectHttpError), + // we need to pass the location URL to the OIDC library + try { + await this.interactionHandler.handleSafe({ operation, oidcInteraction }); + } catch (error: unknown) { + if (RedirectHttpError.isInstance(error)) { + return error.location; + } + throw error; + } + throw new InternalServerError('Could not correctly redirect for the given interaction.'); + }, }; config.routes = { @@ -254,7 +274,7 @@ export class IdentityProviderFactory implements ProviderFactory { */ private configureErrors(config: Configuration): void { config.renderError = async(ctx: KoaContextWithOIDC, out: ErrorOut, error: Error): Promise => { - // This allows us to stream directly to to the response object, see https://github.com/koajs/koa/issues/944 + // This allows us to stream directly to the response object, see https://github.com/koajs/koa/issues/944 ctx.respond = false; const result = await this.errorHandler.handleSafe({ error, preferences: { type: { 'text/plain': 1 }}}); await this.responseWriter.handleSafe({ response: ctx.res, result }); diff --git a/src/identity/interaction/BaseInteractionHandler.ts b/src/identity/interaction/BaseInteractionHandler.ts new file mode 100644 index 000000000..e81afe7a2 --- /dev/null +++ b/src/identity/interaction/BaseInteractionHandler.ts @@ -0,0 +1,51 @@ +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import { InteractionHandler } from './InteractionHandler'; + +/** + * Abstract implementation for handlers that always return a fixed JSON view on a GET. + * POST requests are passed to an abstract function. + * Other methods will be rejected. + */ +export abstract class BaseInteractionHandler extends InteractionHandler { + private readonly view: string; + + protected constructor(view: Record) { + super(); + this.view = JSON.stringify(view); + } + + public async canHandle(input: InteractionHandlerInput): Promise { + await super.canHandle(input); + const { method } = input.operation; + if (method !== 'GET' && method !== 'POST') { + throw new MethodNotAllowedHttpError('Only GET/POST requests are supported.'); + } + } + + public async handle(input: InteractionHandlerInput): Promise { + switch (input.operation.method) { + case 'GET': return this.handleGet(input); + case 'POST': return this.handlePost(input); + default: throw new MethodNotAllowedHttpError(); + } + } + + /** + * Returns a fixed JSON view. + * @param input - Input parameters, only the operation target is used. + */ + protected async handleGet(input: InteractionHandlerInput): Promise { + return new BasicRepresentation(this.view, input.operation.target, APPLICATION_JSON); + } + + /** + * Function that will be called for POST requests. + * Input data remains unchanged. + * @param input - Input operation and OidcInteraction if it exists. + */ + protected abstract handlePost(input: InteractionHandlerInput): Promise; +} diff --git a/src/identity/interaction/CompletingInteractionHandler.ts b/src/identity/interaction/CompletingInteractionHandler.ts index 5b30f2698..3a460011e 100644 --- a/src/identity/interaction/CompletingInteractionHandler.ts +++ b/src/identity/interaction/CompletingInteractionHandler.ts @@ -1,11 +1,11 @@ import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; import { FoundHttpError } from '../../util/errors/FoundHttpError'; +import { BaseInteractionHandler } from './BaseInteractionHandler'; import type { InteractionHandlerInput } from './InteractionHandler'; -import { InteractionHandler } from './InteractionHandler'; import type { InteractionCompleterInput, InteractionCompleter } from './util/InteractionCompleter'; /** - * Abstract class for {@link InteractionHandler}s that need to call an {@link InteractionCompleter}. + * Abstract extension of {@link BaseInteractionHandler} for handlers that need to call an {@link InteractionCompleter}. * This is required by handlers that handle IDP behaviour * and need to complete an OIDC interaction by redirecting back to the client, * such as when logging in. @@ -13,17 +13,17 @@ import type { InteractionCompleterInput, InteractionCompleter } from './util/Int * Calls the InteractionCompleter with the results returned by the helper function * and throw a corresponding {@link FoundHttpError}. */ -export abstract class CompletingInteractionHandler extends InteractionHandler { +export abstract class CompletingInteractionHandler extends BaseInteractionHandler { protected readonly interactionCompleter: InteractionCompleter; - protected constructor(interactionCompleter: InteractionCompleter) { - super(); + protected constructor(view: Record, interactionCompleter: InteractionCompleter) { + super(view); this.interactionCompleter = interactionCompleter; } public async canHandle(input: InteractionHandlerInput): Promise { await super.canHandle(input); - if (!input.oidcInteraction) { + if (input.operation.method === 'POST' && !input.oidcInteraction) { throw new BadRequestHttpError( 'This action can only be performed as part of an OIDC authentication flow.', { errorCode: 'E0002' }, @@ -31,7 +31,7 @@ export abstract class CompletingInteractionHandler extends InteractionHandler { } } - public async handle(input: InteractionHandlerInput): Promise { + public async handlePost(input: InteractionHandlerInput): Promise { // Interaction is defined due to canHandle call const parameters = await this.getCompletionParameters(input as Required); const location = await this.interactionCompleter.handleSafe(parameters); @@ -40,6 +40,7 @@ export abstract class CompletingInteractionHandler extends InteractionHandler { /** * Generates the parameters necessary to call an InteractionCompleter. + * The input parameters are the same that the `handlePost` function was called with. * @param input - The original input parameters to the `handle` function. */ protected abstract getCompletionParameters(input: Required): diff --git a/src/identity/interaction/ControlHandler.ts b/src/identity/interaction/ControlHandler.ts new file mode 100644 index 000000000..34eb78500 --- /dev/null +++ b/src/identity/interaction/ControlHandler.ts @@ -0,0 +1,43 @@ +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import { InternalServerError } from '../../util/errors/InternalServerError'; +import { readJsonStream } from '../../util/StreamUtil'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import { InteractionHandler } from './InteractionHandler'; +import type { InteractionRoute } from './routing/InteractionRoute'; + +const INTERNAL_API_VERSION = '0.3'; + +/** + * Adds `controls` and `apiVersion` fields to the output of its source handler, + * such that clients can predictably find their way to other resources. + * Control paths are determined by the input routes. + */ +export class ControlHandler extends InteractionHandler { + private readonly source: InteractionHandler; + private readonly controls: Record; + + public constructor(source: InteractionHandler, controls: Record) { + super(); + this.source = source; + this.controls = Object.fromEntries( + Object.entries(controls).map(([ control, route ]): [ string, string ] => [ control, route.getPath() ]), + ); + } + + public async canHandle(input: InteractionHandlerInput): Promise { + await this.source.canHandle(input); + } + + public async handle(input: InteractionHandlerInput): Promise { + const result = await this.source.handle(input); + if (result.metadata.contentType !== APPLICATION_JSON) { + throw new InternalServerError('Source handler should return application/json.'); + } + const json = await readJsonStream(result.data); + json.controls = this.controls; + json.apiVersion = INTERNAL_API_VERSION; + return new BasicRepresentation(JSON.stringify(json), result.metadata); + } +} diff --git a/src/identity/interaction/SessionHttpHandler.ts b/src/identity/interaction/ExistingLoginHandler.ts similarity index 83% rename from src/identity/interaction/SessionHttpHandler.ts rename to src/identity/interaction/ExistingLoginHandler.ts index 5e26f88fd..94755405f 100644 --- a/src/identity/interaction/SessionHttpHandler.ts +++ b/src/identity/interaction/ExistingLoginHandler.ts @@ -5,12 +5,12 @@ import type { InteractionHandlerInput } from './InteractionHandler'; import type { InteractionCompleter, InteractionCompleterInput } from './util/InteractionCompleter'; /** - * Simple InteractionHttpHandler that sends the session accountId to the InteractionCompleter as webId. + * Simple CompletingInteractionRoute that returns the session accountId as webId. * This is relevant when a client already logged in this session and tries logging in again. */ -export class SessionHttpHandler extends CompletingInteractionHandler { +export class ExistingLoginHandler extends CompletingInteractionHandler { public constructor(interactionCompleter: InteractionCompleter) { - super(interactionCompleter); + super({}, interactionCompleter); } protected async getCompletionParameters({ operation, oidcInteraction }: Required): diff --git a/src/identity/interaction/HtmlViewHandler.ts b/src/identity/interaction/HtmlViewHandler.ts new file mode 100644 index 000000000..a9d304f7b --- /dev/null +++ b/src/identity/interaction/HtmlViewHandler.ts @@ -0,0 +1,54 @@ +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { cleanPreferences, getTypeWeight } from '../../storage/conversion/ConversionUtil'; +import { APPLICATION_JSON, TEXT_HTML } from '../../util/ContentTypes'; +import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError'; +import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; +import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import type { TemplateEngine } from '../../util/templates/TemplateEngine'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import { InteractionHandler } from './InteractionHandler'; +import type { InteractionRoute } from './routing/InteractionRoute'; + +/** + * Stores the HTML templates associated with specific InteractionRoutes. + * Template keys should be file paths to the templates, + * values should be the corresponding routes. + * + * Will only handle GET operations for which there is a matching template if HTML is more preferred than JSON. + * Reason for doing it like this instead of a standard content negotiation flow + * is because we only want to return the HTML pages on GET requests. * + */ +export class HtmlViewHandler extends InteractionHandler { + private readonly templateEngine: TemplateEngine; + private readonly templates: Record; + + public constructor(templateEngine: TemplateEngine, templates: Record) { + super(); + this.templateEngine = templateEngine; + this.templates = Object.fromEntries( + Object.entries(templates).map(([ template, route ]): [ string, string ] => [ route.getPath(), template ]), + ); + } + + public async canHandle({ operation }: InteractionHandlerInput): Promise { + if (operation.method !== 'GET') { + throw new MethodNotAllowedHttpError(); + } + if (!this.templates[operation.target.path]) { + throw new NotFoundHttpError(); + } + const preferences = cleanPreferences(operation.preferences.type); + const htmlWeight = getTypeWeight(TEXT_HTML, preferences); + const jsonWeight = getTypeWeight(APPLICATION_JSON, preferences); + if (jsonWeight >= htmlWeight) { + throw new NotImplementedHttpError('HTML views are only returned when they are preferred.'); + } + } + + public async handle({ operation }: InteractionHandlerInput): Promise { + const template = this.templates[operation.target.path]; + const result = await this.templateEngine.render({}, { templateFile: template }); + return new BasicRepresentation(result, operation.target, TEXT_HTML); + } +} diff --git a/src/identity/interaction/InteractionHandler.ts b/src/identity/interaction/InteractionHandler.ts index 5bab30adf..3bef6f451 100644 --- a/src/identity/interaction/InteractionHandler.ts +++ b/src/identity/interaction/InteractionHandler.ts @@ -1,5 +1,6 @@ import type { KoaContextWithOIDC } from 'oidc-provider'; import type { Operation } from '../../http/Operation'; +import type { Representation } from '../../http/representation/Representation'; import { APPLICATION_JSON } from '../../util/ContentTypes'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { AsyncHandler } from '../../util/handlers/AsyncHandler'; @@ -9,7 +10,7 @@ export type Interaction = NonNullable> { - type: 'response'; - details?: T; -} - -export interface InteractionErrorResult { - type: 'error'; - error: Error; -} - /** * Handler used for IDP interactions. * Only supports JSON data. */ -export abstract class InteractionHandler extends AsyncHandler { +export abstract class InteractionHandler extends AsyncHandler { public async canHandle({ operation }: InteractionHandlerInput): Promise { - if (operation.body?.metadata.contentType !== APPLICATION_JSON) { + const { contentType } = operation.body.metadata; + if (contentType && contentType !== APPLICATION_JSON) { throw new NotImplementedHttpError('Only application/json data is supported.'); } } diff --git a/src/identity/interaction/PromptHandler.ts b/src/identity/interaction/PromptHandler.ts new file mode 100644 index 000000000..1eb838204 --- /dev/null +++ b/src/identity/interaction/PromptHandler.ts @@ -0,0 +1,28 @@ +import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; +import { FoundHttpError } from '../../util/errors/FoundHttpError'; +import { InteractionHandler } from './InteractionHandler'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import type { InteractionRoute } from './routing/InteractionRoute'; + +/** + * Redirects requests based on the OIDC Interaction prompt. + * Errors in case no match was found. + */ +export class PromptHandler extends InteractionHandler { + private readonly promptRoutes: Record; + + public constructor(promptRoutes: Record) { + super(); + this.promptRoutes = promptRoutes; + } + + public async handle({ oidcInteraction }: InteractionHandlerInput): Promise { + // We also want to redirect on GET so no method check is needed + const prompt = oidcInteraction?.prompt.name; + if (prompt && this.promptRoutes[prompt]) { + const location = this.promptRoutes[prompt].getPath(); + throw new FoundHttpError(location); + } + throw new BadRequestHttpError(`Unsupported prompt: ${prompt}`); + } +} diff --git a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts index e536db2d9..a4ad43ebc 100644 --- a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts @@ -1,49 +1,55 @@ import assert from 'assert'; +import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; +import type { Representation } from '../../../../http/representation/Representation'; import { getLoggerFor } from '../../../../logging/LogUtil'; -import { ensureTrailingSlash, joinUrl } from '../../../../util/PathUtil'; +import { APPLICATION_JSON } from '../../../../util/ContentTypes'; import { readJsonStream } from '../../../../util/StreamUtil'; import type { TemplateEngine } from '../../../../util/templates/TemplateEngine'; -import { InteractionHandler } from '../../InteractionHandler'; -import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler'; +import { BaseInteractionHandler } from '../../BaseInteractionHandler'; +import type { InteractionHandlerInput } from '../../InteractionHandler'; +import type { InteractionRoute } from '../../routing/InteractionRoute'; import type { EmailSender } from '../../util/EmailSender'; import type { AccountStore } from '../storage/AccountStore'; +const forgotPasswordView = { + required: { + email: 'string', + }, +} as const; + export interface ForgotPasswordHandlerArgs { accountStore: AccountStore; - baseUrl: string; - idpPath: string; templateEngine: TemplateEngine<{ resetLink: string }>; emailSender: EmailSender; + resetRoute: InteractionRoute; } /** * Handles the submission of the ForgotPassword form */ -export class ForgotPasswordHandler extends InteractionHandler { +export class ForgotPasswordHandler extends BaseInteractionHandler { protected readonly logger = getLoggerFor(this); private readonly accountStore: AccountStore; - private readonly baseUrl: string; - private readonly idpPath: string; private readonly templateEngine: TemplateEngine<{ resetLink: string }>; private readonly emailSender: EmailSender; + private readonly resetRoute: InteractionRoute; public constructor(args: ForgotPasswordHandlerArgs) { - super(); + super(forgotPasswordView); this.accountStore = args.accountStore; - this.baseUrl = ensureTrailingSlash(args.baseUrl); - this.idpPath = args.idpPath; this.templateEngine = args.templateEngine; this.emailSender = args.emailSender; + this.resetRoute = args.resetRoute; } - public async handle({ operation }: InteractionHandlerInput): Promise> { + public async handlePost({ operation }: InteractionHandlerInput): Promise { // Validate incoming data const { email } = await readJsonStream(operation.body.data); assert(typeof email === 'string' && email.length > 0, 'Email required'); await this.resetPassword(email); - return { type: 'response', details: { email }}; + return new BasicRepresentation(JSON.stringify({ email }), operation.target, APPLICATION_JSON); } /** @@ -68,8 +74,7 @@ export class ForgotPasswordHandler extends InteractionHandler { */ private async sendResetMail(recordId: string, email: string): Promise { this.logger.info(`Sending password reset to ${email}`); - // `joinUrl` strips trailing slash when query parameter gets added - const resetLink = `${joinUrl(this.baseUrl, this.idpPath, 'resetpassword/')}?rid=${recordId}`; + const resetLink = `${this.resetRoute.getPath()}?rid=${encodeURIComponent(recordId)}`; const renderedEmail = await this.templateEngine.render({ resetLink }); await this.emailSender.handleSafe({ recipient: email, diff --git a/src/identity/interaction/email-password/handler/LoginHandler.ts b/src/identity/interaction/email-password/handler/LoginHandler.ts index 2ef26889f..0f8e9f04c 100644 --- a/src/identity/interaction/email-password/handler/LoginHandler.ts +++ b/src/identity/interaction/email-password/handler/LoginHandler.ts @@ -6,9 +6,22 @@ import { readJsonStream } from '../../../../util/StreamUtil'; import { CompletingInteractionHandler } from '../../CompletingInteractionHandler'; import type { InteractionHandlerInput } from '../../InteractionHandler'; import type { InteractionCompleterInput, InteractionCompleter } from '../../util/InteractionCompleter'; - import type { AccountStore } from '../storage/AccountStore'; +const loginView = { + required: { + email: 'string', + password: 'string', + remember: 'boolean', + }, +} as const; + +interface LoginInput { + email: string; + password: string; + remember: boolean; +} + /** * Handles the submission of the Login Form and logs the user in. * Will throw a RedirectHttpError on success. @@ -19,12 +32,13 @@ export class LoginHandler extends CompletingInteractionHandler { private readonly accountStore: AccountStore; public constructor(accountStore: AccountStore, interactionCompleter: InteractionCompleter) { - super(interactionCompleter); + super(loginView, interactionCompleter); this.accountStore = accountStore; } - protected async getCompletionParameters({ operation, oidcInteraction }: Required): + protected async getCompletionParameters(input: Required): Promise { + const { operation, oidcInteraction } = input; const { email, password, remember } = await this.parseInput(operation); // Try to log in, will error if email/password combination is invalid const webId = await this.accountStore.authenticate(email, password); @@ -39,15 +53,12 @@ export class LoginHandler extends CompletingInteractionHandler { } /** - * Parses and validates the input form data. + * Validates the input data. Also makes sure remember is a boolean. * Will throw an error in case something is wrong. - * All relevant data that was correct up to that point will be prefilled. */ - private async parseInput(operation: Operation): Promise<{ email: string; password: string; remember: boolean }> { - const prefilled: Record = {}; + private async parseInput(operation: Operation): Promise { const { email, password, remember } = await readJsonStream(operation.body.data); assert(typeof email === 'string' && email.length > 0, 'Email required'); - prefilled.email = email; assert(typeof password === 'string' && password.length > 0, 'Password required'); return { email, password, remember: Boolean(remember) }; } diff --git a/src/identity/interaction/email-password/handler/RegistrationHandler.ts b/src/identity/interaction/email-password/handler/RegistrationHandler.ts index 16db7c395..a8ae66b5c 100644 --- a/src/identity/interaction/email-password/handler/RegistrationHandler.ts +++ b/src/identity/interaction/email-password/handler/RegistrationHandler.ts @@ -1,27 +1,46 @@ +import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; +import type { Representation } from '../../../../http/representation/Representation'; import { getLoggerFor } from '../../../../logging/LogUtil'; +import { APPLICATION_JSON } from '../../../../util/ContentTypes'; import { readJsonStream } from '../../../../util/StreamUtil'; -import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler'; -import { InteractionHandler } from '../../InteractionHandler'; -import type { RegistrationManager, RegistrationResponse } from '../util/RegistrationManager'; +import { BaseInteractionHandler } from '../../BaseInteractionHandler'; +import type { InteractionHandlerInput } from '../../InteractionHandler'; +import type { RegistrationManager } from '../util/RegistrationManager'; + +const registrationView = { + required: { + email: 'string', + password: 'string', + confirmPassword: 'string', + createWebId: 'boolean', + register: 'boolean', + createPod: 'boolean', + rootPod: 'boolean', + }, + optional: { + webId: 'string', + podName: 'string', + template: 'string', + }, +} as const; /** * Supports registration based on the `RegistrationManager` behaviour. */ -export class RegistrationHandler extends InteractionHandler { +export class RegistrationHandler extends BaseInteractionHandler { protected readonly logger = getLoggerFor(this); private readonly registrationManager: RegistrationManager; public constructor(registrationManager: RegistrationManager) { - super(); + super(registrationView); this.registrationManager = registrationManager; } - public async handle({ operation }: InteractionHandlerInput): - Promise> { + public async handlePost({ operation }: InteractionHandlerInput): Promise { const data = await readJsonStream(operation.body.data); const validated = this.registrationManager.validateInput(data, false); const details = await this.registrationManager.register(validated, false); - return { type: 'response', details }; + return new BasicRepresentation(JSON.stringify(details), operation.target, APPLICATION_JSON); } } diff --git a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts index 431de8ea1..5f9fe520e 100644 --- a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts @@ -1,26 +1,37 @@ import assert from 'assert'; +import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; +import type { Representation } from '../../../../http/representation/Representation'; import { getLoggerFor } from '../../../../logging/LogUtil'; +import { APPLICATION_JSON } from '../../../../util/ContentTypes'; import { readJsonStream } from '../../../../util/StreamUtil'; -import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler'; -import { InteractionHandler } from '../../InteractionHandler'; +import { BaseInteractionHandler } from '../../BaseInteractionHandler'; +import type { InteractionHandlerInput } from '../../InteractionHandler'; import { assertPassword } from '../EmailPasswordUtil'; import type { AccountStore } from '../storage/AccountStore'; +const resetPasswordView = { + required: { + password: 'string', + confirmPassword: 'string', + recordId: 'string', + }, +} as const; + /** - * Handles the submission of the ResetPassword form: - * this is the form that is linked in the reset password email. + * Resets a password if a valid `recordId` is provided, + * which should have been generated by a different handler. */ -export class ResetPasswordHandler extends InteractionHandler { +export class ResetPasswordHandler extends BaseInteractionHandler { protected readonly logger = getLoggerFor(this); private readonly accountStore: AccountStore; public constructor(accountStore: AccountStore) { - super(); + super(resetPasswordView); this.accountStore = accountStore; } - public async handle({ operation }: InteractionHandlerInput): Promise { + public async handlePost({ operation }: InteractionHandlerInput): Promise { // Validate input data const { password, confirmPassword, recordId } = await readJsonStream(operation.body.data); assert( @@ -30,7 +41,7 @@ export class ResetPasswordHandler extends InteractionHandler { assertPassword(password, confirmPassword); await this.resetPassword(recordId, password); - return { type: 'response' }; + return new BasicRepresentation(JSON.stringify({}), operation.target, APPLICATION_JSON); } /** diff --git a/src/identity/interaction/routing/BasicInteractionRoute.ts b/src/identity/interaction/routing/BasicInteractionRoute.ts index 580d3dd12..f726e687e 100644 --- a/src/identity/interaction/routing/BasicInteractionRoute.ts +++ b/src/identity/interaction/routing/BasicInteractionRoute.ts @@ -1,101 +1,43 @@ -import type { Operation } from '../../../http/Operation'; -import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; -import { createErrorMessage, isError } from '../../../util/errors/ErrorUtil'; -import { InternalServerError } from '../../../util/errors/InternalServerError'; -import { RedirectHttpError } from '../../../util/errors/RedirectHttpError'; -import { trimTrailingSlashes } from '../../../util/PathUtil'; -import type { - InteractionHandler, - Interaction, -} from '../InteractionHandler'; -import type { InteractionRoute, TemplatedInteractionResult } from './InteractionRoute'; +import type { Representation } from '../../../http/representation/Representation'; +import { NotFoundHttpError } from '../../../util/errors/NotFoundHttpError'; +import { UnsupportedAsyncHandler } from '../../../util/handlers/UnsupportedAsyncHandler'; +import { InteractionHandler } from '../InteractionHandler'; +import type { InteractionHandlerInput } from '../InteractionHandler'; +import type { InteractionRoute } from './InteractionRoute'; /** - * Default implementation of the InteractionRoute. - * See function comments for specifics. + * Default implementation of an InteractionHandler with an InteractionRoute. + * + * Rejects operations that target a different path, + * otherwise the input parameters get passed to the source handler. + * + * In case no source handler is provided it defaults to an {@link UnsupportedAsyncHandler}. + * This can be useful if you want an object with just the route. */ -export class BasicInteractionRoute implements InteractionRoute { - public readonly route: RegExp; - public readonly handler: InteractionHandler; - public readonly viewTemplates: Record; - public readonly prompt?: string; - public readonly responseTemplates: Record; - public readonly controls: Record; +export class BasicInteractionRoute extends InteractionHandler implements InteractionRoute { + private readonly path: string; + private readonly source: InteractionHandler; - /** - * @param route - Regex to match this route. - * @param viewTemplates - Templates to render on GET requests. - * Keys are content-types, values paths to a template. - * @param handler - Handler to call on POST requests. - * @param prompt - In case of requests to the IDP entry point, the session prompt will be compared to this. - * @param responseTemplates - Templates to render as a response to POST requests when required. - * Keys are content-types, values paths to a template. - * @param controls - Controls to add to the response JSON. - * The keys will be copied and the values will be converted to full URLs. - */ - public constructor(route: string, - viewTemplates: Record, - handler: InteractionHandler, - prompt?: string, - responseTemplates: Record = {}, - controls: Record = {}) { - this.route = new RegExp(route, 'u'); - this.viewTemplates = viewTemplates; - this.handler = handler; - this.prompt = prompt; - this.responseTemplates = responseTemplates; - this.controls = controls; + public constructor(path: string, source?: InteractionHandler) { + super(); + this.path = path; + this.source = source ?? new UnsupportedAsyncHandler('This route has no associated handler.'); } - /** - * Returns the stored controls. - */ - public getControls(): Record { - return this.controls; + public getPath(): string { + return this.path; } - /** - * Checks support by comparing the prompt if the path targets the base URL, - * and otherwise comparing with the stored route regular expression. - */ - public supportsPath(path: string, prompt?: string): boolean { - // In case the request targets the IDP entry point the prompt determines where to go - if (trimTrailingSlashes(path).length === 0 && prompt) { - return this.prompt === prompt; + public async canHandle(input: InteractionHandlerInput): Promise { + const { target } = input.operation; + const path = this.getPath(); + if (target.path !== path) { + throw new NotFoundHttpError(); } - return this.route.test(path); + await this.source.canHandle(input); } - /** - * GET requests return a default response result. - * POST requests return the InteractionHandler result. - * InteractionHandler errors will be converted into response results. - * - * All results will be appended with the matching template paths. - * - * Will error for other methods - */ - public async handleOperation(operation: Operation, oidcInteraction?: Interaction): - Promise { - switch (operation.method) { - case 'GET': - return { type: 'response', templateFiles: this.viewTemplates }; - case 'POST': - try { - const result = await this.handler.handleSafe({ operation, oidcInteraction }); - return { ...result, templateFiles: this.responseTemplates }; - } catch (err: unknown) { - // Redirect errors need to be propagated and not rendered on the response pages. - // Otherwise, the user would be redirected to a new page only containing that error. - if (RedirectHttpError.isInstance(err)) { - throw err; - } - const error = isError(err) ? err : new InternalServerError(createErrorMessage(err)); - // Potentially render the error in the view - return { type: 'error', error, templateFiles: this.viewTemplates }; - } - default: - throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`); - } + public async handle(input: InteractionHandlerInput): Promise { + return this.source.handle(input); } } diff --git a/src/identity/interaction/routing/InteractionRoute.ts b/src/identity/interaction/routing/InteractionRoute.ts index 5287ce9b6..3b0caccff 100644 --- a/src/identity/interaction/routing/InteractionRoute.ts +++ b/src/identity/interaction/routing/InteractionRoute.ts @@ -1,33 +1,9 @@ -import type { Operation } from '../../../http/Operation'; -import type { Interaction, InteractionHandlerResult } from '../InteractionHandler'; - -export type TemplatedInteractionResult = T & { - templateFiles: Record; -}; - /** - * Handles the routing behaviour for IDP handlers. + * An object with a specific path. */ export interface InteractionRoute { /** - * Returns the control fields that should be added to response objects. - * Keys are control names, values are relative URL paths. + * @returns The absolute path of this route. */ - getControls: () => Record; - - /** - * If this route supports the given path. - * @param path - Relative URL path. - * @param prompt - Session prompt if there is one. - */ - supportsPath: (path: string, prompt?: string) => boolean; - - /** - * Handles the given operation. - * @param operation - Operation to handle. - * @param oidcInteraction - Interaction if there is one. - * - * @returns InteractionHandlerResult appended with relevant template files. - */ - handleOperation: (operation: Operation, oidcInteraction?: Interaction) => Promise; + getPath: () => string; } diff --git a/src/identity/interaction/routing/RelativeInteractionRoute.ts b/src/identity/interaction/routing/RelativeInteractionRoute.ts new file mode 100644 index 000000000..6443b5ec3 --- /dev/null +++ b/src/identity/interaction/routing/RelativeInteractionRoute.ts @@ -0,0 +1,18 @@ +import { joinUrl } from '../../../util/PathUtil'; +import type { InteractionHandler } from '../InteractionHandler'; +import { BasicInteractionRoute } from './BasicInteractionRoute'; +import type { InteractionRoute } from './InteractionRoute'; + +/** + * A route that is relative to another route. + * The relative path will be joined to the input base, + * which can either be an absolute URL or an InteractionRoute of which the path will be used. + * The source handler will be called for all operation requests + */ +export class RelativeInteractionRoute extends BasicInteractionRoute { + public constructor(base: InteractionRoute | string, relativePath: string, source?: InteractionHandler) { + const url = typeof base === 'string' ? base : base.getPath(); + const path = joinUrl(url, relativePath); + super(path, source); + } +} diff --git a/src/index.ts b/src/index.ts index f3ed16de2..f965ff15d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -147,6 +147,7 @@ export * from './identity/interaction/email-password/EmailPasswordUtil'; // Identity/Interaction/Routing export * from './identity/interaction/routing/BasicInteractionRoute'; export * from './identity/interaction/routing/InteractionRoute'; +export * from './identity/interaction/routing/RelativeInteractionRoute'; // Identity/Interaction/Util export * from './identity/interaction/util/BaseEmailSender'; @@ -155,9 +156,13 @@ export * from './identity/interaction/util/EmailSender'; export * from './identity/interaction/util/InteractionCompleter'; // Identity/Interaction +export * from './identity/interaction/BaseInteractionHandler'; export * from './identity/interaction/CompletingInteractionHandler'; +export * from './identity/interaction/ExistingLoginHandler'; +export * from './identity/interaction/ControlHandler'; +export * from './identity/interaction/HtmlViewHandler'; export * from './identity/interaction/InteractionHandler'; -export * from './identity/interaction/SessionHttpHandler'; +export * from './identity/interaction/PromptHandler'; // Identity/Ownership export * from './identity/ownership/NoCheckOwnershipValidator'; diff --git a/templates/identity/email-password/confirm.html.ejs b/templates/identity/email-password/consent.html.ejs similarity index 100% rename from templates/identity/email-password/confirm.html.ejs rename to templates/identity/email-password/consent.html.ejs diff --git a/test/unit/identity/ControlHandler.test.ts b/test/unit/identity/ControlHandler.test.ts new file mode 100644 index 000000000..cafa667f2 --- /dev/null +++ b/test/unit/identity/ControlHandler.test.ts @@ -0,0 +1,52 @@ +import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation'; +import { ControlHandler } from '../../../src/identity/interaction/ControlHandler'; +import type { InteractionHandler, InteractionHandlerInput } from '../../../src/identity/interaction/InteractionHandler'; +import type { InteractionRoute } from '../../../src/identity/interaction/routing/InteractionRoute'; +import { APPLICATION_JSON } from '../../../src/util/ContentTypes'; +import { InternalServerError } from '../../../src/util/errors/InternalServerError'; +import { readJsonStream } from '../../../src/util/StreamUtil'; + +describe('A ControlHandler', (): void => { + const input: InteractionHandlerInput = {} as any; + let controls: Record>; + let source: jest.Mocked; + let handler: ControlHandler; + + beforeEach(async(): Promise => { + controls = { + login: { getPath: jest.fn().mockReturnValue('http://example.com/login/') } as any, + register: { getPath: jest.fn().mockReturnValue('http://example.com/register/') } as any, + }; + + source = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue(new BasicRepresentation(JSON.stringify({ data: 'data' }), APPLICATION_JSON)), + } as any; + + handler = new ControlHandler(source, controls); + }); + + it('can handle any input its source can handle.', async(): Promise => { + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + + source.canHandle.mockRejectedValueOnce(new Error('bad data')); + await expect(handler.canHandle(input)).rejects.toThrow('bad data'); + }); + + it('errors in case its source does not return JSON.', async(): Promise => { + source.handle.mockResolvedValueOnce(new BasicRepresentation()); + await expect(handler.handle(input)).rejects.toThrow(InternalServerError); + }); + + it('adds controls to the source response.', async(): Promise => { + const result = await handler.handle(input); + await expect(readJsonStream(result.data)).resolves.toEqual({ + data: 'data', + apiVersion: '0.3', + controls: { + login: 'http://example.com/login/', + register: 'http://example.com/register/', + }, + }); + }); +}); diff --git a/test/unit/identity/IdentityProviderHttpHandler.test.ts b/test/unit/identity/IdentityProviderHttpHandler.test.ts index 6e0deb201..9b5b2fdad 100644 --- a/test/unit/identity/IdentityProviderHttpHandler.test.ts +++ b/test/unit/identity/IdentityProviderHttpHandler.test.ts @@ -1,14 +1,12 @@ import type { Provider } from 'oidc-provider'; import type { Operation } from '../../../src/http/Operation'; -import type { ErrorHandler, ErrorHandlerArgs } from '../../../src/http/output/error/ErrorHandler'; -import type { ResponseDescription } from '../../../src/http/output/response/ResponseDescription'; import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../src/http/representation/Representation'; import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata'; import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory'; import type { IdentityProviderHttpHandlerArgs } from '../../../src/identity/IdentityProviderHttpHandler'; import { IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler'; -import type { InteractionRoute } from '../../../src/identity/interaction/routing/InteractionRoute'; +import type { Interaction, InteractionHandler } from '../../../src/identity/interaction/InteractionHandler'; import type { HttpRequest } from '../../../src/server/HttpRequest'; import type { HttpResponse } from '../../../src/server/HttpResponse'; import { getBestPreference } from '../../../src/storage/conversion/ConversionUtil'; @@ -16,25 +14,20 @@ import type { RepresentationConverter, RepresentationConverterArgs, } from '../../../src/storage/conversion/RepresentationConverter'; -import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; -import { joinUrl } from '../../../src/util/PathUtil'; -import { guardedStreamFrom, readableToString } from '../../../src/util/StreamUtil'; -import { CONTENT_TYPE, SOLID_META } from '../../../src/util/Vocabularies'; +import { APPLICATION_JSON, APPLICATION_X_WWW_FORM_URLENCODED } from '../../../src/util/ContentTypes'; +import { CONTENT_TYPE } from '../../../src/util/Vocabularies'; describe('An IdentityProviderHttpHandler', (): void => { - const apiVersion = '0.2'; - const baseUrl = 'http://test.com/'; - const idpPath = '/idp'; const request: HttpRequest = {} as any; const response: HttpResponse = {} as any; + const oidcInteraction: Interaction = {} as any; let operation: Operation; + let representation: Representation; let providerFactory: jest.Mocked; - let routes: Record<'response' | 'complete' | 'error', jest.Mocked>; - let controls: Record; let converter: jest.Mocked; - let errorHandler: jest.Mocked; let provider: jest.Mocked; - let handler: IdentityProviderHttpHandler; + let handler: jest.Mocked; + let idpHandler: IdentityProviderHttpHandler; beforeEach(async(): Promise => { operation = { @@ -45,44 +38,13 @@ describe('An IdentityProviderHttpHandler', (): void => { }; provider = { - interactionDetails: jest.fn(), + interactionDetails: jest.fn().mockReturnValue(oidcInteraction), } as any; providerFactory = { getProvider: jest.fn().mockResolvedValue(provider), }; - routes = { - response: { - getControls: jest.fn().mockReturnValue({ response: '/routeResponse' }), - supportsPath: jest.fn((path: string): boolean => /^\/routeResponse$/u.test(path)), - handleOperation: jest.fn().mockResolvedValue({ - type: 'response', - details: { key: 'val' }, - templateFiles: { 'text/html': '/response' }, - }), - }, - complete: { - getControls: jest.fn().mockReturnValue({}), - supportsPath: jest.fn((path: string): boolean => /^\/routeComplete$/u.test(path)), - handleOperation: jest.fn().mockResolvedValue({ - type: 'complete', - details: { webId: 'webId' }, - templateFiles: {}, - }), - }, - error: { - getControls: jest.fn().mockReturnValue({}), - supportsPath: jest.fn((path: string): boolean => /^\/routeError$/u.test(path)), - handleOperation: jest.fn().mockResolvedValue({ - type: 'error', - error: new Error('test error'), - templateFiles: { 'text/html': '/response' }, - }), - }, - }; - controls = { response: 'http://test.com/idp/routeResponse' }; - converter = { handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => { // Just find the best match; @@ -92,91 +54,50 @@ describe('An IdentityProviderHttpHandler', (): void => { }), } as any; - errorHandler = { handleSafe: jest.fn(({ error }: ErrorHandlerArgs): ResponseDescription => ({ - statusCode: 400, - data: guardedStreamFrom(`{ "name": "${error.name}", "message": "${error.message}" }`), - })) } as any; + representation = new BasicRepresentation(); + handler = { + handleSafe: jest.fn().mockResolvedValue(representation), + } as any; const args: IdentityProviderHttpHandlerArgs = { - baseUrl, - idpPath, providerFactory, - interactionRoutes: Object.values(routes), converter, - errorHandler, + handler, }; - handler = new IdentityProviderHttpHandler(args); + idpHandler = new IdentityProviderHttpHandler(args); }); - it('throws a 404 if there is no matching route.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, 'invalid'); - await expect(handler.handle({ request, response, operation })).rejects.toThrow(NotFoundHttpError); - }); - - it('creates Representations for InteractionResponseResults.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, '/idp/routeResponse'); - operation.method = 'POST'; - operation.body = new BasicRepresentation('value', 'text/plain'); - const result = (await handler.handle({ request, response, operation }))!; - expect(result).toBeDefined(); - expect(routes.response.handleOperation).toHaveBeenCalledTimes(1); - expect(routes.response.handleOperation).toHaveBeenLastCalledWith(operation, undefined); - expect(operation.body?.metadata.contentType).toBe('application/json'); - - expect(JSON.parse(await readableToString(result.data!))) - .toEqual({ apiVersion, key: 'val', authenticating: false, controls }); + it('returns the handler result as 200 response.', async(): Promise => { + const result = await idpHandler.handle({ operation, request, response }); expect(result.statusCode).toBe(200); - expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe('/response'); + expect(result.data).toBe(representation.data); + expect(result.metadata).toBe(representation.metadata); + expect(handler.handleSafe).toHaveBeenCalledTimes(1); + expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); }); - it('creates Representations for InteractionErrorResults.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, '/idp/routeError'); - operation.method = 'POST'; - operation.preferences = { type: { 'text/html': 1 }}; - - const result = (await handler.handle({ request, response, operation }))!; - expect(result).toBeDefined(); - expect(routes.error.handleOperation).toHaveBeenCalledTimes(1); - expect(routes.error.handleOperation).toHaveBeenLastCalledWith(operation, undefined); - - expect(JSON.parse(await readableToString(result.data!))) - .toEqual({ apiVersion, name: 'Error', message: 'test error', authenticating: false, controls }); - expect(result.statusCode).toBe(400); - expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe('/response'); + it('passes no interaction if the provider call failed.', async(): Promise => { + provider.interactionDetails.mockRejectedValueOnce(new Error('no interaction')); + const result = await idpHandler.handle({ operation, request, response }); + expect(result.statusCode).toBe(200); + expect(result.data).toBe(representation.data); + expect(result.metadata).toBe(representation.metadata); + expect(handler.handleSafe).toHaveBeenCalledTimes(1); + expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation }); }); - it('adds a prefilled field in case error requests had a body.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, '/idp/routeError'); - operation.method = 'POST'; - operation.preferences = { type: { 'text/html': 1 }}; - operation.body = new BasicRepresentation('{ "key": "val" }', 'application/json'); - - const result = (await handler.handle({ request, response, operation }))!; - expect(result).toBeDefined(); - expect(routes.error.handleOperation).toHaveBeenCalledTimes(1); - expect(routes.error.handleOperation).toHaveBeenLastCalledWith(operation, undefined); - expect(operation.body?.metadata.contentType).toBe('application/json'); - - expect(JSON.parse(await readableToString(result.data!))).toEqual( - { apiVersion, name: 'Error', message: 'test error', authenticating: false, controls, prefilled: { key: 'val' }}, + it('converts input bodies to JSON.', async(): Promise => { + operation.body.metadata.contentType = APPLICATION_X_WWW_FORM_URLENCODED; + const result = await idpHandler.handle({ operation, request, response }); + expect(result.statusCode).toBe(200); + expect(result.data).toBe(representation.data); + expect(result.metadata).toBe(representation.metadata); + expect(handler.handleSafe).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { body, ...partialOperation } = operation; + expect(handler.handleSafe).toHaveBeenLastCalledWith( + { operation: expect.objectContaining(partialOperation), oidcInteraction }, ); - expect(result.statusCode).toBe(400); - expect(result.metadata?.contentType).toBe('text/html'); - expect(result.metadata?.get(SOLID_META.template)?.value).toBe('/response'); - }); - - it('indicates to the templates if the request is part of an auth flow.', async(): Promise => { - operation.target.path = joinUrl(baseUrl, '/idp/routeResponse'); - operation.method = 'POST'; - const oidcInteraction = { session: { accountId: 'account' }, prompt: {}} as any; - provider.interactionDetails.mockResolvedValueOnce(oidcInteraction); - routes.response.handleOperation - .mockResolvedValueOnce({ type: 'response', templateFiles: { 'text/html': '/response' }}); - - const result = (await handler.handle({ request, response, operation }))!; - expect(result).toBeDefined(); - expect(JSON.parse(await readableToString(result.data!))).toEqual({ apiVersion, authenticating: true, controls }); + expect(handler.handleSafe.mock.calls[0][0].operation.body.metadata.contentType).toBe(APPLICATION_JSON); }); }); diff --git a/test/unit/identity/configuration/IdentityProviderFactory.test.ts b/test/unit/identity/configuration/IdentityProviderFactory.test.ts index e506b7272..bffb4787e 100644 --- a/test/unit/identity/configuration/IdentityProviderFactory.test.ts +++ b/test/unit/identity/configuration/IdentityProviderFactory.test.ts @@ -1,10 +1,13 @@ -import type { Configuration } from 'oidc-provider'; +import type { Configuration, KoaContextWithOIDC } from 'oidc-provider'; import type { ErrorHandler } from '../../../../src/http/output/error/ErrorHandler'; import type { ResponseWriter } from '../../../../src/http/output/ResponseWriter'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import { IdentityProviderFactory } from '../../../../src/identity/configuration/IdentityProviderFactory'; +import type { Interaction, InteractionHandler } from '../../../../src/identity/interaction/InteractionHandler'; import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory'; import type { HttpResponse } from '../../../../src/server/HttpResponse'; import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; +import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; /* eslint-disable @typescript-eslint/naming-convention */ jest.mock('oidc-provider', (): any => ({ @@ -28,10 +31,13 @@ const routes = { describe('An IdentityProviderFactory', (): void => { let baseConfig: Configuration; - const baseUrl = 'http://test.com/foo/'; + const baseUrl = 'http://example.com/foo/'; const oidcPath = '/oidc'; - const idpPath = '/idp'; - const webId = 'http://alice.test.com/card#me'; + const webId = 'http://alice.example.com/card#me'; + const redirectUrl = 'http://example.com/login/'; + const oidcInteraction: Interaction = {} as any; + let ctx: KoaContextWithOIDC; + let interactionHandler: jest.Mocked; let adapterFactory: jest.Mocked; let storage: jest.Mocked>; let errorHandler: jest.Mocked; @@ -41,6 +47,17 @@ describe('An IdentityProviderFactory', (): void => { beforeEach(async(): Promise => { baseConfig = { claims: { webid: [ 'webid', 'client_webid' ]}}; + ctx = { + method: 'GET', + request: { + href: 'http://example.com/idp/', + }, + } as any; + + interactionHandler = { + handleSafe: jest.fn().mockRejectedValue(new FoundHttpError(redirectUrl)), + } as any; + adapterFactory = { createStorageAdapter: jest.fn().mockReturnValue('adapter!'), }; @@ -61,25 +78,13 @@ describe('An IdentityProviderFactory', (): void => { adapterFactory, baseUrl, oidcPath, - idpPath, + interactionHandler, storage, errorHandler, responseWriter, }); }); - it('errors if the idpPath parameter does not start with a slash.', async(): Promise => { - expect((): any => new IdentityProviderFactory(baseConfig, { - adapterFactory, - baseUrl, - oidcPath, - idpPath: 'idp', - storage, - errorHandler, - responseWriter, - })).toThrow('idpPath needs to start with a /'); - }); - it('creates a correct configuration.', async(): Promise => { // This is the output of our mock function const provider = await factory.getProvider() as any; @@ -98,7 +103,7 @@ describe('An IdentityProviderFactory', (): void => { expect(config.jwks).toEqual({ keys: [ expect.objectContaining({ kty: 'RSA' }) ]}); expect(config.routes).toEqual(routes); - expect((config.interactions?.url as any)()).toBe('/idp/'); + await expect((config.interactions?.url as any)(ctx, oidcInteraction)).resolves.toBe(redirectUrl); expect((config.audiences as any)(null, null, {}, 'access_token')).toBe('solid'); expect((config.audiences as any)(null, null, { clientId: 'clientId' }, 'client_credentials')).toBe('clientId'); @@ -123,6 +128,17 @@ describe('An IdentityProviderFactory', (): void => { expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }}); }); + it('errors if there is no valid interaction redirect.', async(): Promise => { + interactionHandler.handleSafe.mockRejectedValueOnce(new Error('bad data')); + const provider = await factory.getProvider() as any; + const { config } = provider as { config: Configuration }; + await expect((config.interactions?.url as any)(ctx, oidcInteraction)).rejects.toThrow('bad data'); + + interactionHandler.handleSafe.mockResolvedValueOnce(new BasicRepresentation()); + await expect((config.interactions?.url as any)(ctx, oidcInteraction)) + .rejects.toThrow('Could not correctly redirect for the given interaction.'); + }); + it('copies a field from the input config if values need to be added to it.', async(): Promise => { baseConfig.cookies = { long: { signed: true }, @@ -131,7 +147,7 @@ describe('An IdentityProviderFactory', (): void => { adapterFactory, baseUrl, oidcPath, - idpPath, + interactionHandler, storage, errorHandler, responseWriter, @@ -153,7 +169,7 @@ describe('An IdentityProviderFactory', (): void => { adapterFactory, baseUrl, oidcPath, - idpPath, + interactionHandler, storage, errorHandler, responseWriter, diff --git a/test/unit/identity/interaction/BaseInteractionHandler.test.ts b/test/unit/identity/interaction/BaseInteractionHandler.test.ts new file mode 100644 index 000000000..4736ee2d0 --- /dev/null +++ b/test/unit/identity/interaction/BaseInteractionHandler.test.ts @@ -0,0 +1,70 @@ +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { Representation } from '../../../../src/http/representation/Representation'; +import { BaseInteractionHandler } from '../../../../src/identity/interaction/BaseInteractionHandler'; +import type { InteractionHandlerInput } from '../../../../src/identity/interaction/InteractionHandler'; +import { APPLICATION_JSON } from '../../../../src/util/ContentTypes'; +import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError'; +import { readJsonStream } from '../../../../src/util/StreamUtil'; + +class DummyBaseInteractionHandler extends BaseInteractionHandler { + public constructor() { + super({ view: 'view' }); + } + + public async handlePost(input: InteractionHandlerInput): Promise { + return new BasicRepresentation(JSON.stringify({ data: 'data' }), input.operation.target, APPLICATION_JSON); + } +} + +describe('A BaseInteractionHandler', (): void => { + const handler = new DummyBaseInteractionHandler(); + + it('can only handle GET and POST requests.', async(): Promise => { + const operation: Operation = { + method: 'DELETE', + target: { path: 'http://example.com/foo' }, + body: new BasicRepresentation(), + preferences: {}, + }; + await expect(handler.canHandle({ operation })).rejects.toThrow(MethodNotAllowedHttpError); + + operation.method = 'GET'; + await expect(handler.canHandle({ operation })).resolves.toBeUndefined(); + + operation.method = 'POST'; + await expect(handler.canHandle({ operation })).resolves.toBeUndefined(); + }); + + it('returns the view on GET requests.', async(): Promise => { + const operation: Operation = { + method: 'GET', + target: { path: 'http://example.com/foo' }, + body: new BasicRepresentation(), + preferences: {}, + }; + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ view: 'view' }); + }); + + it('calls the handlePost function on POST requests.', async(): Promise => { + const operation: Operation = { + method: 'POST', + target: { path: 'http://example.com/foo' }, + body: new BasicRepresentation(), + preferences: {}, + }; + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ data: 'data' }); + }); + + it('rejects other methods.', async(): Promise => { + const operation: Operation = { + method: 'DELETE', + target: { path: 'http://example.com/foo' }, + body: new BasicRepresentation(), + preferences: {}, + }; + await expect(handler.handle({ operation })).rejects.toThrow(MethodNotAllowedHttpError); + }); +}); diff --git a/test/unit/identity/interaction/CompletingInteractionHandler.test.ts b/test/unit/identity/interaction/CompletingInteractionHandler.test.ts index 4fbc4cb69..34a221d4f 100644 --- a/test/unit/identity/interaction/CompletingInteractionHandler.test.ts +++ b/test/unit/identity/interaction/CompletingInteractionHandler.test.ts @@ -1,7 +1,10 @@ import type { Operation } from '../../../../src/http/Operation'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import { CompletingInteractionHandler } from '../../../../src/identity/interaction/CompletingInteractionHandler'; -import type { Interaction, InteractionHandlerInput } from '../../../../src/identity/interaction/InteractionHandler'; +import type { + Interaction, + InteractionHandlerInput, +} from '../../../../src/identity/interaction/InteractionHandler'; import type { InteractionCompleter, InteractionCompleterInput, @@ -11,7 +14,7 @@ import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplemen const webId = 'http://alice.test.com/card#me'; class DummyCompletingInteractionHandler extends CompletingInteractionHandler { public constructor(interactionCompleter: InteractionCompleter) { - super(interactionCompleter); + super({}, interactionCompleter); } public async getCompletionParameters(input: Required): Promise { @@ -28,7 +31,10 @@ describe('A CompletingInteractionHandler', (): void => { beforeEach(async(): Promise => { const representation = new BasicRepresentation('', 'application/json'); - operation = { body: representation } as any; + operation = { + method: 'POST', + body: representation, + } as any; interactionCompleter = { handleSafe: jest.fn().mockResolvedValue(location), @@ -39,10 +45,15 @@ describe('A CompletingInteractionHandler', (): void => { it('calls the parent JSON canHandle check.', async(): Promise => { operation.body.metadata.contentType = 'application/x-www-form-urlencoded'; - await expect(handler.canHandle({ operation } as any)).rejects.toThrow(NotImplementedHttpError); + await expect(handler.canHandle({ operation, oidcInteraction } as any)).rejects.toThrow(NotImplementedHttpError); }); - it('errors if no OidcInteraction is defined.', async(): Promise => { + it('can handle GET requests without interaction.', async(): Promise => { + operation.method = 'GET'; + await expect(handler.canHandle({ operation } as any)).resolves.toBeUndefined(); + }); + + it('errors if no OidcInteraction is defined on POST requests.', async(): Promise => { const error = expect.objectContaining({ statusCode: 400, message: 'This action can only be performed as part of an OIDC authentication flow.', diff --git a/test/unit/identity/interaction/SessionHttpHandler.test.ts b/test/unit/identity/interaction/ExistingLoginHandler.test.ts similarity index 50% rename from test/unit/identity/interaction/SessionHttpHandler.test.ts rename to test/unit/identity/interaction/ExistingLoginHandler.test.ts index ff6206e2b..c649e0a23 100644 --- a/test/unit/identity/interaction/SessionHttpHandler.test.ts +++ b/test/unit/identity/interaction/ExistingLoginHandler.test.ts @@ -1,27 +1,17 @@ -import type { InteractionHandlerInput, Interaction } from '../../../../src/identity/interaction/InteractionHandler'; -import { SessionHttpHandler } from '../../../../src/identity/interaction/SessionHttpHandler'; +import { ExistingLoginHandler } from '../../../../src/identity/interaction/ExistingLoginHandler'; +import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler'; import type { InteractionCompleter, - InteractionCompleterInput, } from '../../../../src/identity/interaction/util/InteractionCompleter'; +import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { createPostJsonOperation } from './email-password/handler/Util'; -class PublicSessionHttpHandler extends SessionHttpHandler { - public constructor(interactionCompleter: InteractionCompleter) { - super(interactionCompleter); - } - - public async getCompletionParameters(input: Required): Promise { - return super.getCompletionParameters(input); - } -} - -describe('A SessionHttpHandler', (): void => { +describe('An ExistingLoginHandler', (): void => { const webId = 'http://test.com/id#me'; let oidcInteraction: Interaction; let interactionCompleter: jest.Mocked; - let handler: PublicSessionHttpHandler; + let handler: ExistingLoginHandler; beforeEach(async(): Promise => { oidcInteraction = { session: { accountId: webId }} as any; @@ -30,18 +20,19 @@ describe('A SessionHttpHandler', (): void => { handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'), } as any; - handler = new PublicSessionHttpHandler(interactionCompleter); + handler = new ExistingLoginHandler(interactionCompleter); }); it('requires an oidcInteraction with a defined session.', async(): Promise => { oidcInteraction.session = undefined; - await expect(handler.getCompletionParameters({ operation: {} as any, oidcInteraction })) + await expect(handler.handle({ operation: createPostJsonOperation({}), oidcInteraction })) .rejects.toThrow(NotImplementedHttpError); }); it('returns the correct completion parameters.', async(): Promise => { const operation = createPostJsonOperation({ remember: true }); - await expect(handler.getCompletionParameters({ operation, oidcInteraction })) - .resolves.toEqual({ oidcInteraction, webId, shouldRemember: true }); + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); + expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); + expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ oidcInteraction, webId, shouldRemember: true }); }); }); diff --git a/test/unit/identity/interaction/HtmlViewHandler.test.ts b/test/unit/identity/interaction/HtmlViewHandler.test.ts new file mode 100644 index 000000000..5836f8c7f --- /dev/null +++ b/test/unit/identity/interaction/HtmlViewHandler.test.ts @@ -0,0 +1,68 @@ +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import { HtmlViewHandler } from '../../../../src/identity/interaction/HtmlViewHandler'; +import type { InteractionRoute } from '../../../../src/identity/interaction/routing/InteractionRoute'; +import { TEXT_HTML } from '../../../../src/util/ContentTypes'; +import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError'; +import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; +import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import { readableToString } from '../../../../src/util/StreamUtil'; +import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine'; + +describe('An HtmlViewHandler', (): void => { + let operation: Operation; + let templates: Record>; + let templateEngine: TemplateEngine; + let handler: HtmlViewHandler; + + beforeEach(async(): Promise => { + operation = { + method: 'GET', + target: { path: 'http://example.com/idp/login/' }, + preferences: { type: { 'text/html': 1 }}, + body: new BasicRepresentation(), + }; + + templates = { + '/templates/login.html.ejs': { getPath: jest.fn().mockReturnValue('http://example.com/idp/login/') } as any, + '/templates/register.html.ejs': { getPath: jest.fn().mockReturnValue('http://example.com/idp/register/') } as any, + }; + + templateEngine = { + render: jest.fn().mockReturnValue(Promise.resolve('')), + }; + + handler = new HtmlViewHandler(templateEngine, templates); + }); + + it('rejects non-GET requests.', async(): Promise => { + operation.method = 'POST'; + await expect(handler.canHandle({ operation })).rejects.toThrow(MethodNotAllowedHttpError); + }); + + it('rejects unsupported paths.', async(): Promise => { + operation.target.path = 'http://example.com/idp/otherPath/'; + await expect(handler.canHandle({ operation })).rejects.toThrow(NotFoundHttpError); + }); + + it('rejects requests that do not prefer HTML to JSON.', async(): Promise => { + operation.preferences = { type: { '*/*': 1 }}; + await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError); + + operation.preferences = { type: { 'application/json': 1, 'text/html': 1 }}; + await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError); + + operation.preferences = { type: { 'application/json': 1, 'text/html': 0.8 }}; + await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError); + }); + + it('can handle matching requests.', async(): Promise => { + await expect(handler.canHandle({ operation })).resolves.toBeUndefined(); + }); + + it('returns the resolved template.', async(): Promise => { + const result = await handler.handle({ operation }); + expect(result.metadata.contentType).toBe(TEXT_HTML); + await expect(readableToString(result.data)).resolves.toBe(''); + }); +}); diff --git a/test/unit/identity/interaction/InteractionHandler.test.ts b/test/unit/identity/interaction/InteractionHandler.test.ts index f74cee567..167fef7aa 100644 --- a/test/unit/identity/interaction/InteractionHandler.test.ts +++ b/test/unit/identity/interaction/InteractionHandler.test.ts @@ -1,22 +1,20 @@ import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; -import type { - InteractionResponseResult, -} from '../../../../src/identity/interaction/InteractionHandler'; +import type { Representation } from '../../../../src/http/representation/Representation'; import { InteractionHandler, } from '../../../../src/identity/interaction/InteractionHandler'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; class SimpleInteractionHandler extends InteractionHandler { - public async handle(): Promise { - return { type: 'response' }; + public async handle(): Promise { + return new BasicRepresentation(); } } describe('An InteractionHandler', (): void => { const handler = new SimpleInteractionHandler(); - it('only supports JSON data.', async(): Promise => { + it('only supports JSON data or empty bodies.', async(): Promise => { let representation = new BasicRepresentation('{}', 'application/json'); await expect(handler.canHandle({ operation: { body: representation }} as any)).resolves.toBeUndefined(); @@ -24,6 +22,7 @@ describe('An InteractionHandler', (): void => { await expect(handler.canHandle({ operation: { body: representation }} as any)) .rejects.toThrow(NotImplementedHttpError); - await expect(handler.canHandle({ operation: {}} as any)).rejects.toThrow(NotImplementedHttpError); + representation = new BasicRepresentation(); + await expect(handler.canHandle({ operation: { body: representation }} as any)).resolves.toBeUndefined(); }); }); diff --git a/test/unit/identity/interaction/PromptHandler.test.ts b/test/unit/identity/interaction/PromptHandler.test.ts new file mode 100644 index 000000000..05a6834ef --- /dev/null +++ b/test/unit/identity/interaction/PromptHandler.test.ts @@ -0,0 +1,37 @@ +import type { Operation } from '../../../../src/http/Operation'; +import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler'; +import { PromptHandler } from '../../../../src/identity/interaction/PromptHandler'; +import type { InteractionRoute } from '../../../../src/identity/interaction/routing/InteractionRoute'; +import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; + +describe('A PromptHandler', (): void => { + const operation: Operation = { target: { path: 'http://example.com/test/' }} as any; + let oidcInteraction: Interaction; + let promptRoutes: Record>; + let handler: PromptHandler; + + beforeEach(async(): Promise => { + oidcInteraction = { prompt: { name: 'login' }} as any; + promptRoutes = { + login: { getPath: jest.fn().mockReturnValue('http://example.com/idp/login/') } as any, + }; + handler = new PromptHandler(promptRoutes); + }); + + it('errors if there is no interaction.', async(): Promise => { + await expect(handler.handle({ operation })).rejects.toThrow(BadRequestHttpError); + }); + + it('errors if the prompt is unsupported.', async(): Promise => { + oidcInteraction.prompt.name = 'unsupported'; + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(BadRequestHttpError); + }); + + it('throws a redirect error with the correct location.', async(): Promise => { + const error = expect.objectContaining({ + statusCode: 302, + location: 'http://example.com/idp/login/', + }); + await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(error); + }); +}); 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 f905e963c..92701c0a3 100644 --- a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts @@ -3,7 +3,9 @@ import { ForgotPasswordHandler, } from '../../../../../../src/identity/interaction/email-password/handler/ForgotPasswordHandler'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; +import type { InteractionRoute } from '../../../../../../src/identity/interaction/routing/InteractionRoute'; import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender'; +import { readJsonStream } from '../../../../../../src/util/StreamUtil'; import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine'; import { createPostJsonOperation } from './Util'; @@ -11,11 +13,10 @@ describe('A ForgotPasswordHandler', (): void => { let operation: Operation; const email = 'test@test.email'; const recordId = '123456'; - const html = `Reset Password`; + const html = `Reset Password`; let accountStore: AccountStore; - const baseUrl = 'http://test.com/base/'; - const idpPath = '/idp'; let templateEngine: TemplateEngine<{ resetLink: string }>; + let resetRoute: jest.Mocked; let emailSender: EmailSender; let handler: ForgotPasswordHandler; @@ -30,16 +31,19 @@ describe('A ForgotPasswordHandler', (): void => { render: jest.fn().mockResolvedValue(html), } as any; + resetRoute = { + getPath: jest.fn().mockReturnValue('http://test.com/base/idp/resetpassword/'), + } as any; + emailSender = { handleSafe: jest.fn(), } as any; handler = new ForgotPasswordHandler({ accountStore, - baseUrl, - idpPath, templateEngine, emailSender, + resetRoute, }); }); @@ -52,14 +56,15 @@ describe('A ForgotPasswordHandler', (): void => { it('does not send a mail if a ForgotPassword record could not be generated.', async(): Promise => { (accountStore.generateForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('error'); - await expect(handler.handle({ operation })).resolves - .toEqual({ type: 'response', details: { email }}); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ email }); expect(emailSender.handleSafe).toHaveBeenCalledTimes(0); }); it('sends a mail if a ForgotPassword record could be generated.', async(): Promise => { - await expect(handler.handle({ operation })).resolves - .toEqual({ type: 'response', details: { email }}); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({ email }); + expect(result.metadata.contentType).toBe('application/json'); expect(emailSender.handleSafe).toHaveBeenCalledTimes(1); expect(emailSender.handleSafe).toHaveBeenLastCalledWith({ recipient: email, 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 456c85917..c4a9392e7 100644 --- a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts @@ -5,22 +5,11 @@ import type { InteractionHandlerInput, } from '../../../../../../src/identity/interaction/InteractionHandler'; import type { - InteractionCompleterInput, InteractionCompleter, } from '../../../../../../src/identity/interaction/util/InteractionCompleter'; - +import { FoundHttpError } from '../../../../../../src/util/errors/FoundHttpError'; import { createPostJsonOperation } from './Util'; -class PublicLoginHandler extends LoginHandler { - public constructor(accountStore: AccountStore, interactionCompleter: InteractionCompleter) { - super(accountStore, interactionCompleter); - } - - public async getCompletionParameters(input: Required): Promise { - return super.getCompletionParameters(input); - } -} - describe('A LoginHandler', (): void => { const webId = 'http://alice.test.com/card#me'; const email = 'alice@test.email'; @@ -28,7 +17,7 @@ describe('A LoginHandler', (): void => { let input: Required; let accountStore: jest.Mocked; let interactionCompleter: jest.Mocked; - let handler: PublicLoginHandler; + let handler: LoginHandler; beforeEach(async(): Promise => { input = { oidcInteraction } as any; @@ -42,41 +31,43 @@ describe('A LoginHandler', (): void => { handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'), } as any; - handler = new PublicLoginHandler(accountStore, interactionCompleter); + handler = new LoginHandler(accountStore, interactionCompleter); }); it('errors on invalid emails.', async(): Promise => { input.operation = createPostJsonOperation({}); - await expect(handler.getCompletionParameters(input)).rejects.toThrow('Email required'); + await expect(handler.handle(input)).rejects.toThrow('Email required'); input.operation = createPostJsonOperation({ email: [ 'a', 'b' ]}); - await expect(handler.getCompletionParameters(input)).rejects.toThrow('Email required'); + await expect(handler.handle(input)).rejects.toThrow('Email required'); }); it('errors on invalid passwords.', async(): Promise => { input.operation = createPostJsonOperation({ email }); - await expect(handler.getCompletionParameters(input)).rejects.toThrow('Password required'); + await expect(handler.handle(input)).rejects.toThrow('Password required'); input.operation = createPostJsonOperation({ email, password: [ 'a', 'b' ]}); - await expect(handler.getCompletionParameters(input)).rejects.toThrow('Password required'); + await expect(handler.handle(input)).rejects.toThrow('Password required'); }); it('throws an error if there is a problem.', async(): Promise => { input.operation = createPostJsonOperation({ email, password: 'password!' }); accountStore.authenticate.mockRejectedValueOnce(new Error('auth failed!')); - await expect(handler.getCompletionParameters(input)).rejects.toThrow('auth failed!'); + await expect(handler.handle(input)).rejects.toThrow('auth failed!'); }); it('throws an error if the account does not have the correct settings.', async(): Promise => { input.operation = createPostJsonOperation({ email, password: 'password!' }); accountStore.getSettings.mockResolvedValueOnce({ useIdp: false }); - await expect(handler.getCompletionParameters(input)) + await expect(handler.handle(input)) .rejects.toThrow('This server is not an identity provider for this account.'); }); it('returns the correct completion parameters.', async(): Promise => { input.operation = createPostJsonOperation({ email, password: 'password!' }); - await expect(handler.getCompletionParameters(input)) - .resolves.toEqual({ oidcInteraction, webId, shouldRemember: false }); + await expect(handler.handle(input)).rejects.toThrow(FoundHttpError); + expect(accountStore.authenticate).toHaveBeenCalledTimes(1); expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, 'password!'); + expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); + expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ oidcInteraction, webId, shouldRemember: false }); }); }); 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 1a08ea2cd..52ff1c0e1 100644 --- a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts @@ -5,6 +5,7 @@ import { import type { RegistrationManager, RegistrationParams, RegistrationResponse, } from '../../../../../../src/identity/interaction/email-password/util/RegistrationManager'; +import { readJsonStream } from '../../../../../../src/util/StreamUtil'; import { createPostJsonOperation } from './Util'; describe('A RegistrationHandler', (): void => { @@ -41,10 +42,9 @@ describe('A RegistrationHandler', (): void => { it('converts the stream to json and sends it to the registration manager.', async(): Promise => { const params = { email: 'alice@test.email', password: 'superSecret' }; operation = createPostJsonOperation(params); - await expect(handler.handle({ operation })).resolves.toEqual({ - type: 'response', - details, - }); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual(details); + expect(result.metadata.contentType).toBe('application/json'); expect(registrationManager.validateInput).toHaveBeenCalledTimes(1); expect(registrationManager.validateInput).toHaveBeenLastCalledWith(params, false); 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 73d7c851e..cf589350d 100644 --- a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts @@ -3,6 +3,7 @@ import { ResetPasswordHandler, } from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordHandler'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; +import { readJsonStream } from '../../../../../../src/util/StreamUtil'; import { createPostJsonOperation } from './Util'; describe('A ResetPasswordHandler', (): void => { @@ -46,7 +47,9 @@ describe('A ResetPasswordHandler', (): void => { it('renders a message on success.', async(): Promise => { operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!', recordId }, url); - await expect(handler.handle({ operation })).resolves.toEqual({ type: 'response' }); + const result = await handler.handle({ operation }); + await expect(readJsonStream(result.data)).resolves.toEqual({}); + expect(result.metadata.contentType).toBe('application/json'); expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1); expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId); expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1); diff --git a/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts b/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts index 28bda2c1c..7ca15e559 100644 --- a/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts +++ b/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts @@ -1,92 +1,59 @@ +import type { Operation } from '../../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation'; +import type { Representation } from '../../../../../src/http/representation/Representation'; import type { InteractionHandler, } from '../../../../../src/identity/interaction/InteractionHandler'; import { BasicInteractionRoute } from '../../../../../src/identity/interaction/routing/BasicInteractionRoute'; -import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; -import { FoundHttpError } from '../../../../../src/util/errors/FoundHttpError'; -import { InternalServerError } from '../../../../../src/util/errors/InternalServerError'; +import { APPLICATION_JSON } from '../../../../../src/util/ContentTypes'; +import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; +import { createPostJsonOperation } from '../email-password/handler/Util'; describe('A BasicInteractionRoute', (): void => { - const path = '^/route$'; - const viewTemplates = { 'text/html': '/viewTemplate' }; - let handler: jest.Mocked; - const prompt = 'login'; - const responseTemplates = { 'text/html': '/responseTemplate' }; - const controls = { login: '/route' }; - const response = { type: 'response' }; + const path = 'http://example.com/idp/path/'; + let operation: Operation; + let representation: Representation; + let source: jest.Mocked; let route: BasicInteractionRoute; beforeEach(async(): Promise => { - handler = { - handleSafe: jest.fn().mockResolvedValue(response), + operation = createPostJsonOperation({}, 'http://example.com/idp/path/'); + + representation = new BasicRepresentation(JSON.stringify({}), APPLICATION_JSON); + + source = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue(representation), } as any; - route = new BasicInteractionRoute(path, viewTemplates, handler, prompt, responseTemplates, controls); + route = new BasicInteractionRoute(path, source); }); - it('returns its controls.', async(): Promise => { - expect(route.getControls()).toEqual(controls); + it('returns the given path.', async(): Promise => { + expect(route.getPath()).toBe('http://example.com/idp/path/'); }); - it('supports a path if it matches the stored route.', async(): Promise => { - expect(route.supportsPath('/route')).toBe(true); - expect(route.supportsPath('/notRoute')).toBe(false); + it('rejects other paths.', async(): Promise => { + operation = createPostJsonOperation({}, 'http://example.com/idp/otherPath/'); + await expect(route.canHandle({ operation })).rejects.toThrow(NotFoundHttpError); }); - it('supports prompts when targeting the base path.', async(): Promise => { - expect(route.supportsPath('/', prompt)).toBe(true); - expect(route.supportsPath('/notRoute', prompt)).toBe(false); - expect(route.supportsPath('/', 'notPrompt')).toBe(false); + it('rejects input its source cannot handle.', async(): Promise => { + source.canHandle.mockRejectedValueOnce(new Error('bad data')); + await expect(route.canHandle({ operation })).rejects.toThrow('bad data'); }); - it('returns a response result on a GET request.', async(): Promise => { - await expect(route.handleOperation({ method: 'GET' } as any)) - .resolves.toEqual({ type: 'response', templateFiles: viewTemplates }); + it('can handle requests its source can handle.', async(): Promise => { + await expect(route.canHandle({ operation })).resolves.toBeUndefined(); }); - it('returns the result of the InteractionHandler on POST requests.', async(): Promise => { - await expect(route.handleOperation({ method: 'POST' } as any)) - .resolves.toEqual({ ...response, templateFiles: responseTemplates }); - expect(handler.handleSafe).toHaveBeenCalledTimes(1); - expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation: { method: 'POST' }}); + it('lets its source handle requests.', async(): Promise => { + await expect(route.handle({ operation })).resolves.toBe(representation); }); - it('creates an error result in case the InteractionHandler errors.', async(): Promise => { - const error = new Error('bad data'); - handler.handleSafe.mockRejectedValueOnce(error); - await expect(route.handleOperation({ method: 'POST' } as any)) - .resolves.toEqual({ type: 'error', error, templateFiles: viewTemplates }); - }); - - it('re-throws redirect errors.', async(): Promise => { - const error = new FoundHttpError('http://test.com/redirect'); - handler.handleSafe.mockRejectedValueOnce(error); - await expect(route.handleOperation({ method: 'POST' } as any)).rejects.toThrow(error); - }); - - it('creates an internal error in case of non-native errors.', async(): Promise => { - handler.handleSafe.mockRejectedValueOnce('notAnError'); - await expect(route.handleOperation({ method: 'POST' } as any)).resolves.toEqual({ - type: 'error', - error: new InternalServerError('Unknown error: notAnError'), - templateFiles: viewTemplates, - }); - }); - - it('errors for non-supported operations.', async(): Promise => { - const prom = route.handleOperation({ method: 'DELETE', target: { path: '/route' }} as any); - await expect(prom).rejects.toThrow(BadRequestHttpError); - await expect(prom).rejects.toThrow('Unsupported request: DELETE /route'); - expect(handler.handleSafe).toHaveBeenCalledTimes(0); - }); - - it('defaults to empty controls.', async(): Promise => { - route = new BasicInteractionRoute(path, viewTemplates, handler, prompt); - expect(route.getControls()).toEqual({}); - }); - - it('defaults to empty response templates.', async(): Promise => { - route = new BasicInteractionRoute(path, viewTemplates, handler, prompt); - await expect(route.handleOperation({ method: 'POST' } as any)).resolves.toEqual({ ...response, templateFiles: {}}); + it('defaults to an UnsupportedAsyncHandler if no source is provided.', async(): Promise => { + route = new BasicInteractionRoute(path); + await expect(route.canHandle({ operation })).rejects.toThrow('This route has no associated handler.'); + await expect(route.handle({ operation })).rejects.toThrow('This route has no associated handler.'); }); }); diff --git a/test/unit/identity/interaction/routing/RelativeInteractionRoute.test.ts b/test/unit/identity/interaction/routing/RelativeInteractionRoute.test.ts new file mode 100644 index 000000000..9d8bb8ba8 --- /dev/null +++ b/test/unit/identity/interaction/routing/RelativeInteractionRoute.test.ts @@ -0,0 +1,30 @@ +import type { + InteractionHandler, +} from '../../../../../src/identity/interaction/InteractionHandler'; +import type { InteractionRoute } from '../../../../../src/identity/interaction/routing/InteractionRoute'; +import { RelativeInteractionRoute } from '../../../../../src/identity/interaction/routing/RelativeInteractionRoute'; + +describe('A RelativeInteractionRoute', (): void => { + const relativePath = '/relative/'; + let route: jest.Mocked; + let source: jest.Mocked; + let relativeRoute: RelativeInteractionRoute; + + beforeEach(async(): Promise => { + route = { + getPath: jest.fn().mockReturnValue('http://example.com/'), + } as any; + + source = { + canHandle: jest.fn(), + } as any; + }); + + it('returns the joined path.', async(): Promise => { + relativeRoute = new RelativeInteractionRoute(route, relativePath, source); + expect(relativeRoute.getPath()).toBe('http://example.com/relative/'); + + relativeRoute = new RelativeInteractionRoute('http://example.com/', relativePath, source); + expect(relativeRoute.getPath()).toBe('http://example.com/relative/'); + }); +});