Repository: solid/community-server
Pull request #820 (2021-07-19 07:59:32)

Several IDP architecture changes
Part of the changes needed for #722. But I realized an entire PR for that would probably be too much at the sime time so already pushing some intermediate changes.

First commit: moves the logic for the hidden field in the reset password page to the html template instead of the server logic. This page was the only one that had a different logic for GET/POST requests, making it more difficult to have a general architecture change for the handlers. With this change it's the same as all the other ones in that no extra server logic is required for the GET request.

Second commit: The `InitialInteractionHandler` is the class that gets the request that get sent to `/idp/`. It then has to decide which actor has to handle the request based on the session parameters. With this change it does that by sending a 302 with the correct URL instead of linking to the relevant handler directly. This way its behaviour is more similar to the other handlers and hierarchically it fits more next to the other ones.

joachimvh: > Okay with me, but the client-side code might make things more brittle than they need to be.
> 
> > This page was the only one that had a different logic for GET/POST requests
> 
> How about we keep the same logic, but `POST` also looks for fields in the query string? Then we don't have to copy them over on the client.
> (Note my remark about not having set `action`.)

I just realized that we are going to need some handlers for handling GET requests (see #827) so there's no real point anymore in doing this reset password commit. I might still keep it and use the no-action solution without query parameter since that still seems cleaner to me.

Patch:
diff --git a/config/identity/handler/interaction/handler.json b/config/identity/handler/interaction/handler.json
index 4fc29ef5e..94e2b9553 100644
--- a/config/identity/handler/interaction/handler.json
+++ b/config/identity/handler/interaction/handler.json
@@ -19,7 +19,6 @@
         { "@id": "urn:solid-server:auth:password:LoginInteractionHandler" },
         { "@id": "urn:solid-server:auth:password:SessionInteractionHandler" },
         { "@id": "urn:solid-server:auth:password:ForgotPasswordInteractionHandler" },
-        { "@id": "urn:solid-server:auth:password:ResetPasswordViewInteractionHandler" },
         { "@id": "urn:solid-server:auth:password:ResetPasswordInteractionHandler" }
       ]
     },
diff --git a/config/identity/handler/interaction/handlers/initial.json b/config/identity/handler/interaction/handlers/initial.json
index c4639e91a..64ba556b3 100644
--- a/config/identity/handler/interaction/handlers/initial.json
+++ b/config/identity/handler/interaction/handlers/initial.json
@@ -9,19 +9,14 @@
       "allowedPathNames": [ "^/idp/?$" ],
       "handler": {
         "@type": "InitialInteractionHandler",
-        "renderHandlerMap": [
+        "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
+        "redirectMap": [
           {
-            "InitialInteractionHandler:_renderHandlerMap_key": "consent",
-            "InitialInteractionHandler:_renderHandlerMap_value": {
-              "@type": "TemplateHandler",
-              "templateEngine": {
-                "@type": "EjsTemplateEngine",
-                "template": "$PACKAGE_ROOT/templates/identity/email-password/confirm.html.ejs"
-              }
-            }
+            "InitialInteractionHandler:_redirectMap_key": "consent",
+            "InitialInteractionHandler:_redirectMap_value": "/idp/confirm"
           }
         ],
-        "renderHandlerMap_default": { "@id": "urn:solid-server:auth:password:LoginRenderHandler" }
+        "redirectMap_default": "/idp/login"
       }
     }
   ]
diff --git a/config/identity/handler/interaction/handlers/reset-password.json b/config/identity/handler/interaction/handlers/reset-password.json
index 042bf59f5..4fb0059e0 100644
--- a/config/identity/handler/interaction/handlers/reset-password.json
+++ b/config/identity/handler/interaction/handlers/reset-password.json
@@ -2,29 +2,17 @@
   "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
   "comment": "Exports 2 handlers: one for viewing the page and one for doing the reset.",
   "@graph": [
-    {
-      "comment": "Renders the reset password page",
-      "@id": "urn:solid-server:auth:password:ResetPasswordViewInteractionHandler",
-      "@type": "RouterHandler",
-      "allowedMethods": [ "GET" ],
-      "allowedPathNames": [ "^/idp/resetpassword/?$" ],
-      "handler": {
-        "@type": "ResetPasswordViewHandler",
-        "renderHandler": { "@id": "urn:solid-server:auth:password:ResetPasswordRenderHandler" }
-      }
-    },
     {
       "comment": "Handles the reset password page submission",
       "@id": "urn:solid-server:auth:password:ResetPasswordInteractionHandler",
-      "@type": "RouterHandler",
-      "allowedMethods": [ "POST" ],
-      "allowedPathNames": [ "^/idp/resetpassword/?$" ],
-      "handler": {
+      "@type": "IdpRouteController",
+      "pathName": "^/idp/resetpassword/[^/]+$",
+      "postHandler": {
         "@type": "ResetPasswordHandler",
         "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
-        "args_renderHandler": { "@id": "urn:solid-server:auth:password:ResetPasswordRenderHandler" },
         "args_messageRenderHandler": { "@id": "urn:solid-server:auth:password:MessageRenderHandler" }
-      }
+      },
+      "renderHandler": { "@id": "urn:solid-server:auth:password:ResetPasswordRenderHandler" }
     },
 
     {
diff --git a/config/identity/handler/interaction/handlers/session.json b/config/identity/handler/interaction/handlers/session.json
index a1bc0b942..3efcc2584 100644
--- a/config/identity/handler/interaction/handlers/session.json
+++ b/config/identity/handler/interaction/handlers/session.json
@@ -4,12 +4,22 @@
     {
       "comment": "Handles confirm requests",
       "@id": "urn:solid-server:auth:password:SessionInteractionHandler",
-      "@type": "RouterHandler",
-      "allowedMethods": [ "POST" ],
-      "allowedPathNames": [ "^/idp/confirm/?$" ],
-      "handler": {
+      "@type": "IdpRouteController",
+      "pathName": "^/idp/confirm/?$",
+      "postHandler": {
         "@type": "SessionHttpHandler",
         "interactionCompleter": { "@id": "urn:solid-server:auth:password:InteractionCompleter" }
+      },
+      "renderHandler": { "@id": "urn:solid-server:auth:password:ConfirmRenderHandler" }
+    },
+
+    {
+      "comment": "Renders the confirmation page",
+      "@id": "urn:solid-server:auth:password:ConfirmRenderHandler",
+      "@type": "TemplateHandler",
+      "templateEngine": {
+        "@type": "EjsTemplateEngine",
+        "template": "$PACKAGE_ROOT/templates/identity/email-password/confirm.html.ejs"
       }
     }
   ]
diff --git a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts
index 8c49385af..db20b87fb 100644
--- a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts
+++ b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts
@@ -79,7 +79,7 @@ export class ForgotPasswordHandler extends InteractionHttpHandler {
    */
   private async sendResetMail(recordId: string, email: string): Promise<void> {
     this.logger.info(`Sending password reset to ${email}`);
-    const resetLink = urljoin(this.baseUrl, this.idpPath, `resetpassword?rid=${recordId}`);
+    const resetLink = urljoin(this.baseUrl, this.idpPath, `resetpassword/${recordId}`);
     const renderedEmail = await this.templateEngine.render({ resetLink });
     await this.emailSender.handleSafe({
       recipient: email,
diff --git a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts
index 02af6e89c..9fdecc8d5 100644
--- a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts
+++ b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts
@@ -3,15 +3,12 @@ import { getLoggerFor } from '../../../../logging/LogUtil';
 import type { HttpHandlerInput } from '../../../../server/HttpHandler';
 import { HttpHandler } from '../../../../server/HttpHandler';
 import type { TemplateHandler } from '../../../../server/util/TemplateHandler';
-import { createErrorMessage } from '../../../../util/errors/ErrorUtil';
 import { getFormDataRequestBody } from '../../util/FormDataUtil';
-import { assertPassword } from '../EmailPasswordUtil';
+import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil';
 import type { AccountStore } from '../storage/AccountStore';
-import type { ResetPasswordRenderHandler } from './ResetPasswordRenderHandler';
 
 export interface ResetPasswordHandlerArgs {
   accountStore: AccountStore;
-  renderHandler: ResetPasswordRenderHandler;
   messageRenderHandler: TemplateHandler<{ message: string }>;
 }
 
@@ -23,26 +20,24 @@ export class ResetPasswordHandler extends HttpHandler {
   protected readonly logger = getLoggerFor(this);
 
   private readonly accountStore: AccountStore;
-  private readonly renderHandler: ResetPasswordRenderHandler;
   private readonly messageRenderHandler: TemplateHandler<{ message: string }>;
 
   public constructor(args: ResetPasswordHandlerArgs) {
     super();
     this.accountStore = args.accountStore;
-    this.renderHandler = args.renderHandler;
     this.messageRenderHandler = args.messageRenderHandler;
   }
 
   public async handle(input: HttpHandlerInput): Promise<void> {
-    let prefilledRecordId = '';
     try {
+      // Extract record ID from request URL
+      const recordId = /\/([^/]+)$/u.exec(input.request.url!)?.[1];
       // Validate input data
-      const { password, confirmPassword, recordId } = await getFormDataRequestBody(input.request);
+      const { password, confirmPassword } = await getFormDataRequestBody(input.request);
       assert(
         typeof recordId === 'string' && recordId.length > 0,
         'Invalid request. Open the link from your email again',
       );
-      prefilledRecordId = recordId;
       assertPassword(password, confirmPassword);
 
       await this.resetPassword(recordId, password);
@@ -52,14 +47,8 @@ export class ResetPasswordHandler extends HttpHandler {
           message: 'Your password was successfully reset.',
         },
       });
-    } catch (err: unknown) {
-      await this.renderHandler.handleSafe({
-        response: input.response,
-        contents: {
-          errorMessage: createErrorMessage(err),
-          recordId: prefilledRecordId,
-        },
-      });
+    } catch (error: unknown) {
+      throwIdpInteractionError(error);
     }
   }
 
diff --git a/src/identity/interaction/email-password/handler/ResetPasswordRenderHandler.ts b/src/identity/interaction/email-password/handler/ResetPasswordRenderHandler.ts
deleted file mode 100644
index 0f949acdb..000000000
--- a/src/identity/interaction/email-password/handler/ResetPasswordRenderHandler.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { TemplateHandler } from '../../../../server/util/TemplateHandler';
-
-export interface ResetPasswordRenderHandlerProps {
-  errorMessage: string;
-  recordId: string;
-}
-
-/**
- * A special {@link RenderHandler} for the Reset Password form
- * that includes the required props for rendering the reset password form.
- */
-export abstract class ResetPasswordRenderHandler extends TemplateHandler<ResetPasswordRenderHandlerProps> {}
diff --git a/src/identity/interaction/email-password/handler/ResetPasswordViewHandler.ts b/src/identity/interaction/email-password/handler/ResetPasswordViewHandler.ts
deleted file mode 100644
index bf9c1fb3a..000000000
--- a/src/identity/interaction/email-password/handler/ResetPasswordViewHandler.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import assert from 'assert';
-import { parse } from 'url';
-import type { HttpHandlerInput } from '../../../../server/HttpHandler';
-import { HttpHandler } from '../../../../server/HttpHandler';
-import { throwIdpInteractionError } from '../EmailPasswordUtil';
-import type { ResetPasswordRenderHandler } from './ResetPasswordRenderHandler';
-
-/**
- * Handles the creation of the Reset Password form
- * after the user clicks on it from the link provided in the email.
- */
-export class ResetPasswordViewHandler extends HttpHandler {
-  private readonly renderHandler: ResetPasswordRenderHandler;
-
-  public constructor(renderHandler: ResetPasswordRenderHandler) {
-    super();
-    this.renderHandler = renderHandler;
-  }
-
-  public async handle({ request, response }: HttpHandlerInput): Promise<void> {
-    try {
-      assert(request.url, 'The request must have a URL');
-      const recordId = parse(request.url, true).query.rid;
-      assert(
-        typeof recordId === 'string' && recordId.length > 0,
-        'A forgot password record ID must be provided. Use the link you have received by email.',
-      );
-      await this.renderHandler.handleSafe({
-        response,
-        contents: { errorMessage: '', recordId },
-      });
-    } catch (error: unknown) {
-      throwIdpInteractionError(error, {});
-    }
-  }
-}
diff --git a/src/identity/interaction/util/InitialInteractionHandler.ts b/src/identity/interaction/util/InitialInteractionHandler.ts
index 663a72749..2ca8dcac6 100644
--- a/src/identity/interaction/util/InitialInteractionHandler.ts
+++ b/src/identity/interaction/util/InitialInteractionHandler.ts
@@ -1,47 +1,45 @@
+import urljoin from 'url-join';
 import { getLoggerFor } from '../../../logging/LogUtil';
 import type { InteractionHttpHandlerInput } from '../InteractionHttpHandler';
 import { InteractionHttpHandler } from '../InteractionHttpHandler';
-import type { IdpRenderHandler } from './IdpRenderHandler';
 
-export interface RenderHandlerMap {
-  [key: string]: IdpRenderHandler;
-  default: IdpRenderHandler;
+export interface RedirectMap {
+  [key: string]: string;
+  default: string;
 }
 
 /**
- * An {@link InteractionHttpHandler} that redirects requests
- * to a specific {@link IdpRenderHandler} based on their prompt.
+ * An {@link InteractionHttpHandler} that redirects requests based on their prompt.
  * A list of possible prompts can be found at https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
  * In case there is no prompt or there is no match in the input map,
- * the `default` render handler will be used.
+ * the `default` redirect will be used.
  *
- * Specifically, the prompt determines how the server should handle re-authentication and consent.
- *
- * Since this class specifically redirects to render handlers,
- * it is advised to wrap it in a {@link RouterHandler} that only allows GET requests.
+ * Specifically, this is used to redirect the client to the correct way to login,
+ * such as a login page, or a confirmation page if a login procedure already succeeded previously.
  */
 export class InitialInteractionHandler extends InteractionHttpHandler {
   protected readonly logger = getLoggerFor(this);
 
-  private readonly renderHandlerMap: RenderHandlerMap;
+  private readonly baseUrl: string;
+  private readonly redirectMap: RedirectMap;
 
-  public constructor(renderHandlerMap: RenderHandlerMap) {
+  public constructor(baseUrl: string, redirectMap: RedirectMap) {
     super();
-    this.renderHandlerMap = renderHandlerMap;
+    this.baseUrl = baseUrl;
+    this.redirectMap = redirectMap;
   }
 
   public async handle({ request, response, provider }: InteractionHttpHandlerInput): Promise<void> {
+    // Find the matching redirect in the map or take the default
     const interactionDetails = await provider.interactionDetails(request, response);
-    const name = interactionDetails.prompt.name in this.renderHandlerMap ? interactionDetails.prompt.name : 'default';
+    const name = interactionDetails.prompt.name in this.redirectMap ? interactionDetails.prompt.name : 'default';
 
-    this.logger.debug(`Calling ${name} render handler.`);
+    // Create a valid redirect URL
+    const location = urljoin(this.baseUrl, this.redirectMap[name]);
+    this.logger.debug(`Redirecting ${name} prompt to ${location}.`);
 
-    await this.renderHandlerMap[name].handleSafe({
-      response,
-      contents: {
-        errorMessage: '',
-        prefilled: {},
-      },
-    });
+    // Redirect to the result
+    response.writeHead(302, { location });
+    response.end();
   }
 }
diff --git a/src/index.ts b/src/index.ts
index 844cc3c10..30c510649 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -24,8 +24,6 @@ export * from './identity/interaction/email-password/handler/ForgotPasswordHandl
 export * from './identity/interaction/email-password/handler/LoginHandler';
 export * from './identity/interaction/email-password/handler/RegistrationHandler';
 export * from './identity/interaction/email-password/handler/ResetPasswordHandler';
-export * from './identity/interaction/email-password/handler/ResetPasswordRenderHandler';
-export * from './identity/interaction/email-password/handler/ResetPasswordViewHandler';
 
 // Identity/Interaction/Email-Password/Storage
 export * from './identity/interaction/email-password/storage/AccountStore';
diff --git a/templates/identity/email-password/reset-password.html.ejs b/templates/identity/email-password/reset-password.html.ejs
index 3ebcada86..788d02294 100644
--- a/templates/identity/email-password/reset-password.html.ejs
+++ b/templates/identity/email-password/reset-password.html.ejs
@@ -13,7 +13,7 @@
   </header>
   <main>
     <h1>Reset password</h1>
-    <form action="/idp/resetpassword" method="post">
+    <form method="post">
       <%if (errorMessage) { %>
         <p class="error"><%= errorMessage %></p>
       <% } %>
@@ -26,11 +26,9 @@
           </li>
           <li>
             <label for="confirmPassword">Confirm new password</label>
-            <input id="confirmPassword" type="password" name="password" placeholder="">
+            <input id="confirmPassword" type="password" name="confirmPassword" placeholder="">
           </li>
         </ol>
-
-        <input type="hidden" name="recordId" value="<%= recordId %>" />
       </fieldset>
 
       <p class="actions"><button type="submit" name="submit">Reset password</button></p>


Test patch:
diff --git a/test/integration/Identity.test.ts b/test/integration/Identity.test.ts
index 0631e317b..867ab82ac 100644
--- a/test/integration/Identity.test.ts
+++ b/test/integration/Identity.test.ts
@@ -200,7 +200,7 @@ describe('A Solid server with IDP', (): void => {
       const match = /(http:.*)$/u.exec(mail.text);
       expect(match).toBeDefined();
       nextUrl = match![1];
-      expect(nextUrl).toContain('resetpassword?rid=');
+      expect(nextUrl).toMatch(/\/resetpassword\/[^/]+$/u);
     });
 
     it('resets the password through the given link.', async(): Promise<void> => {
@@ -209,14 +209,12 @@ describe('A Solid server with IDP', (): void => {
       expect(res.status).toBe(200);
       const text = await res.text();
       const relative = load(text)('form').attr('action');
-      expect(typeof relative).toBe('string');
+      // Reset password form has no action causing the current URL to be used
+      expect(relative).toBeUndefined();
 
-      const recordId = load(text)('input[name="recordId"]').attr('value');
-      expect(typeof recordId).toBe('string');
-
-      // POST the new password
-      const formData = stringify({ password: password2, confirmPassword: password2, recordId });
-      res = await fetch(new URL(relative!, baseUrl).href, {
+      // POST the new password to the same URL
+      const formData = stringify({ password: password2, confirmPassword: password2 });
+      res = await fetch(nextUrl, {
         method: 'POST',
         headers: { 'content-type': APPLICATION_X_WWW_FORM_URLENCODED },
         body: formData,
diff --git a/test/integration/IdentityTestState.ts b/test/integration/IdentityTestState.ts
index 167c849a1..5ff326830 100644
--- a/test/integration/IdentityTestState.ts
+++ b/test/integration/IdentityTestState.ts
@@ -87,7 +87,12 @@ export class IdentityTestState {
     expect(nextUrl.startsWith(this.oidcIssuer)).toBeTruthy();
 
     // Need to catch the redirect so we can copy the cookies
-    const res = await this.fetchIdp(nextUrl);
+    let res = await this.fetchIdp(nextUrl);
+    expect(res.status).toBe(302);
+    nextUrl = res.headers.get('location')!;
+
+    // Redirect from main page to specific page (login or confirmation)
+    res = await this.fetchIdp(nextUrl);
     expect(res.status).toBe(302);
     nextUrl = res.headers.get('location')!;
 
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 a3369c52e..f62549014 100644
--- a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts
+++ b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts
@@ -15,7 +15,7 @@ describe('A ForgotPasswordHandler', (): void => {
   const response: HttpResponse = {} as any;
   const email = 'test@test.email';
   const recordId = '123456';
-  const html = `<a href="/base/idp/resetpassword?rid=${recordId}">Reset Password</a>`;
+  const html = `<a href="/base/idp/resetpassword/${recordId}">Reset Password</a>`;
   const renderParams = { response, contents: { errorMessage: '', prefilled: { email }}};
   const provider: Provider = {} as any;
   let messageRenderHandler: IdpRenderHandler;
@@ -76,7 +76,7 @@ describe('A ForgotPasswordHandler', (): void => {
     expect(emailSender.handleSafe).toHaveBeenLastCalledWith({
       recipient: email,
       subject: 'Reset your password',
-      text: `To reset your password, go to this link: http://test.com/base/idp/resetpassword?rid=${recordId}`,
+      text: `To reset your password, go to this link: http://test.com/base/idp/resetpassword/${recordId}`,
       html,
     });
     expect(messageRenderHandler.handleSafe).toHaveBeenCalledTimes(1);
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 9e6b2cddf..d9ff3cab0 100644
--- a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts
+++ b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts
@@ -1,9 +1,6 @@
 import {
   ResetPasswordHandler,
 } from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordHandler';
-import type {
-  ResetPasswordRenderHandler,
-} from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordRenderHandler';
 import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
 import type { HttpRequest } from '../../../../../../src/server/HttpRequest';
 import type { HttpResponse } from '../../../../../../src/server/HttpResponse';
@@ -14,9 +11,9 @@ describe('A ResetPasswordHandler', (): void => {
   let request: HttpRequest;
   const response: HttpResponse = {} as any;
   const recordId = '123456';
+  const url = `/resetURL/${recordId}`;
   const email = 'alice@test.email';
   let accountStore: AccountStore;
-  let renderHandler: ResetPasswordRenderHandler;
   let messageRenderHandler: TemplateHandler<{ message: string }>;
   let handler: ResetPasswordHandler;
 
@@ -27,52 +24,39 @@ describe('A ResetPasswordHandler', (): void => {
       changePassword: jest.fn(),
     } as any;
 
-    renderHandler = {
-      handleSafe: jest.fn(),
-    } as any;
-
     messageRenderHandler = {
       handleSafe: jest.fn(),
     } as any;
 
     handler = new ResetPasswordHandler({
       accountStore,
-      renderHandler,
       messageRenderHandler,
     });
   });
 
-  it('renders errors for non-string recordIds.', async(): Promise<void> => {
+  it('errors for non-string recordIds.', async(): Promise<void> => {
     const errorMessage = 'Invalid request. Open the link from your email again';
     request = createPostFormRequest({});
-    await expect(handler.handle({ request, response })).resolves.toBeUndefined();
-    expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1);
-    expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, contents: { errorMessage, recordId: '' }});
-    request = createPostFormRequest({ recordId: [ 'a', 'b' ]});
-    await expect(handler.handle({ request, response })).resolves.toBeUndefined();
-    expect(renderHandler.handleSafe).toHaveBeenCalledTimes(2);
-    expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, contents: { errorMessage, recordId: '' }});
+    await expect(handler.handle({ request, response })).rejects.toThrow(errorMessage);
+    request = createPostFormRequest({}, '');
+    await expect(handler.handle({ request, response })).rejects.toThrow(errorMessage);
   });
 
-  it('renders errors for invalid passwords.', async(): Promise<void> => {
+  it('errors for invalid passwords.', async(): Promise<void> => {
     const errorMessage = 'Password and confirmation do not match';
-    request = createPostFormRequest({ recordId, password: 'password!', confirmPassword: 'otherPassword!' });
-    await expect(handler.handle({ request, response })).resolves.toBeUndefined();
-    expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1);
-    expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, contents: { errorMessage, recordId }});
+    request = createPostFormRequest({ password: 'password!', confirmPassword: 'otherPassword!' }, url);
+    await expect(handler.handle({ request, response })).rejects.toThrow(errorMessage);
   });
 
-  it('renders errors for invalid emails.', async(): Promise<void> => {
+  it('errors for invalid emails.', async(): Promise<void> => {
     const errorMessage = 'This reset password link is no longer valid.';
-    request = createPostFormRequest({ recordId, password: 'password!', confirmPassword: 'password!' });
+    request = createPostFormRequest({ password: 'password!', confirmPassword: 'password!' }, url);
     (accountStore.getForgotPasswordRecord as jest.Mock).mockResolvedValueOnce(undefined);
-    await expect(handler.handle({ request, response })).resolves.toBeUndefined();
-    expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1);
-    expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, contents: { errorMessage, recordId }});
+    await expect(handler.handle({ request, response })).rejects.toThrow(errorMessage);
   });
 
   it('renders a message on success.', async(): Promise<void> => {
-    request = createPostFormRequest({ recordId, password: 'password!', confirmPassword: 'password!' });
+    request = createPostFormRequest({ password: 'password!', confirmPassword: 'password!' }, url);
     await expect(handler.handle({ request, response })).resolves.toBeUndefined();
     expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1);
     expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId);
@@ -87,10 +71,8 @@ describe('A ResetPasswordHandler', (): void => {
 
   it('has a default error for non-native errors.', async(): Promise<void> => {
     const errorMessage = 'Unknown error: not native';
-    request = createPostFormRequest({ recordId, password: 'password!', confirmPassword: 'password!' });
+    request = createPostFormRequest({ password: 'password!', confirmPassword: 'password!' }, url);
     (accountStore.getForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('not native');
-    await expect(handler.handle({ request, response })).resolves.toBeUndefined();
-    expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1);
-    expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, contents: { errorMessage, recordId }});
+    await expect(handler.handle({ request, response })).rejects.toThrow(errorMessage);
   });
 });
diff --git a/test/unit/identity/interaction/email-password/handler/ResetPasswordViewHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ResetPasswordViewHandler.test.ts
deleted file mode 100644
index 482076df2..000000000
--- a/test/unit/identity/interaction/email-password/handler/ResetPasswordViewHandler.test.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import type {
-  ResetPasswordRenderHandler,
-} from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordRenderHandler';
-import {
-  ResetPasswordViewHandler,
-} from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordViewHandler';
-import type { HttpRequest } from '../../../../../../src/server/HttpRequest';
-import type { HttpResponse } from '../../../../../../src/server/HttpResponse';
-
-describe('A ResetPasswordViewHandler', (): void => {
-  let request: HttpRequest;
-  const response: HttpResponse = {} as any;
-  let renderHandler: ResetPasswordRenderHandler;
-  let handler: ResetPasswordViewHandler;
-
-  beforeEach(async(): Promise<void> => {
-    request = {} as any;
-
-    renderHandler = {
-      handleSafe: jest.fn(),
-    } as any;
-
-    handler = new ResetPasswordViewHandler(renderHandler);
-  });
-
-  it('requires a URL.', async(): Promise<void> => {
-    await expect(handler.handle({ request, response })).rejects.toThrow('The request must have a URL');
-  });
-
-  it('requires a record ID.', async(): Promise<void> => {
-    request.url = '/foo';
-    await expect(handler.handle({ request, response })).rejects
-      .toThrow('A forgot password record ID must be provided. Use the link you have received by email.');
-    request.url = '/foo?wrong=recordId';
-    await expect(handler.handle({ request, response })).rejects
-      .toThrow('A forgot password record ID must be provided. Use the link you have received by email.');
-  });
-
-  it('renders the response.', async(): Promise<void> => {
-    request.url = '/foo?rid=recordId';
-    await expect(handler.handle({ request, response })).resolves.toBeUndefined();
-    expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1);
-    expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({
-      response,
-      contents: { errorMessage: '', recordId: 'recordId' },
-    });
-  });
-});
diff --git a/test/unit/identity/interaction/email-password/handler/Util.ts b/test/unit/identity/interaction/email-password/handler/Util.ts
index 0348f17b7..73bfdc8e6 100644
--- a/test/unit/identity/interaction/email-password/handler/Util.ts
+++ b/test/unit/identity/interaction/email-password/handler/Util.ts
@@ -6,9 +6,11 @@ import { guardedStreamFrom } from '../../../../../../src/util/StreamUtil';
  * Creates a mock HttpRequest which is a stream of an object encoded as application/x-www-form-urlencoded
  * and a matching content-type header.
  * @param data - Object to encode.
+ * @param url - URL value of the request.
  */
-export function createPostFormRequest(data: NodeJS.Dict<any>): HttpRequest {
+export function createPostFormRequest(data: NodeJS.Dict<any>, url?: string): HttpRequest {
   const request = guardedStreamFrom(stringify(data)) as HttpRequest;
   request.headers = { 'content-type': 'application/x-www-form-urlencoded' };
+  request.url = url;
   return request;
 }
diff --git a/test/unit/identity/interaction/util/InitialInteractionHandler.test.ts b/test/unit/identity/interaction/util/InitialInteractionHandler.test.ts
index 45889df20..eaacf4e4c 100644
--- a/test/unit/identity/interaction/util/InitialInteractionHandler.test.ts
+++ b/test/unit/identity/interaction/util/InitialInteractionHandler.test.ts
@@ -1,45 +1,43 @@
+import type { MockResponse } from 'node-mocks-http';
+import { createResponse } from 'node-mocks-http';
 import type { Provider } from 'oidc-provider';
-import type { RenderHandlerMap } from '../../../../../src/identity/interaction/util/InitialInteractionHandler';
+import type { RedirectMap } from '../../../../../src/identity/interaction/util/InitialInteractionHandler';
 import { InitialInteractionHandler } from '../../../../../src/identity/interaction/util/InitialInteractionHandler';
 import type { HttpRequest } from '../../../../../src/server/HttpRequest';
-import type { HttpResponse } from '../../../../../src/server/HttpResponse';
 
 describe('An InitialInteractionHandler', (): void => {
+  const baseUrl = 'http://test.com/';
   const request: HttpRequest = {} as any;
-  const response: HttpResponse = {} as any;
-  let provider: Provider;
+  let response: MockResponse<any>;
+  let provider: jest.Mocked<Provider>;
   // `Interaction` type is not exposed
   let details: any;
-  let map: RenderHandlerMap;
+  let map: RedirectMap;
   let handler: InitialInteractionHandler;
 
   beforeEach(async(): Promise<void> => {
+    response = createResponse();
+
     map = {
-      default: { handleSafe: jest.fn() },
-      test: { handleSafe: jest.fn() },
-    } as any;
+      default: '/idp/login',
+      test: '/idp/test',
+    };
 
     details = { prompt: { name: 'test' }};
     provider = {
       interactionDetails: jest.fn().mockResolvedValue(details),
     } as any;
 
-    handler = new InitialInteractionHandler(map);
+    handler = new InitialInteractionHandler(baseUrl, map);
   });
 
   it('uses the named handler if it is found.', async(): Promise<void> => {
     await expect(handler.handle({ request, response, provider })).resolves.toBeUndefined();
     expect(provider.interactionDetails).toHaveBeenCalledTimes(1);
     expect(provider.interactionDetails).toHaveBeenLastCalledWith(request, response);
-    expect(map.default.handleSafe).toHaveBeenCalledTimes(0);
-    expect(map.test.handleSafe).toHaveBeenCalledTimes(1);
-    expect(map.test.handleSafe).toHaveBeenLastCalledWith({
-      response,
-      contents: {
-        errorMessage: '',
-        prefilled: {},
-      },
-    });
+    expect(response._isEndCalled()).toBe(true);
+    expect(response.getHeader('location')).toBe('http://test.com/idp/test');
+    expect(response.statusCode).toBe(302);
   });
 
   it('uses the default handler if there is no match.', async(): Promise<void> => {
@@ -47,14 +45,8 @@ describe('An InitialInteractionHandler', (): void => {
     await expect(handler.handle({ request, response, provider })).resolves.toBeUndefined();
     expect(provider.interactionDetails).toHaveBeenCalledTimes(1);
     expect(provider.interactionDetails).toHaveBeenLastCalledWith(request, response);
-    expect(map.default.handleSafe).toHaveBeenCalledTimes(1);
-    expect(map.test.handleSafe).toHaveBeenCalledTimes(0);
-    expect(map.default.handleSafe).toHaveBeenLastCalledWith({
-      response,
-      contents: {
-        errorMessage: '',
-        prefilled: {},
-      },
-    });
+    expect(response._isEndCalled()).toBe(true);
+    expect(response.getHeader('location')).toBe('http://test.com/idp/login');
+    expect(response.statusCode).toBe(302);
   });
 });