feat: Pass optional Interaction to InteractionHandlers

This commit is contained in:
Joachim Van Herwegen 2021-08-03 15:57:11 +02:00
parent 66accacde8
commit d3de5f3114
14 changed files with 113 additions and 119 deletions

View File

@ -8,10 +8,7 @@
"route": "^/confirm/?$", "route": "^/confirm/?$",
"prompt": "consent", "prompt": "consent",
"viewTemplate": "@css:templates/identity/email-password/confirm.html.ejs", "viewTemplate": "@css:templates/identity/email-password/confirm.html.ejs",
"handler": { "handler": { "@type": "SessionHttpHandler" }
"@type": "SessionHttpHandler",
"providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }
}
} }
] ]
} }

View File

@ -11,9 +11,11 @@ import { assertError, createErrorMessage } from '../util/errors/ErrorUtil';
import { InternalServerError } from '../util/errors/InternalServerError'; import { InternalServerError } from '../util/errors/InternalServerError';
import { trimTrailingSlashes } from '../util/PathUtil'; import { trimTrailingSlashes } from '../util/PathUtil';
import type { ProviderFactory } from './configuration/ProviderFactory'; import type { ProviderFactory } from './configuration/ProviderFactory';
import type { InteractionHandler, import type {
InteractionHandlerResult } from './interaction/email-password/handler/InteractionHandler'; Interaction,
InteractionHandler,
InteractionHandlerResult,
} from './interaction/email-password/handler/InteractionHandler';
import { IdpInteractionError } from './interaction/util/IdpInteractionError'; import { IdpInteractionError } from './interaction/util/IdpInteractionError';
import type { InteractionCompleter } from './interaction/util/InteractionCompleter'; import type { InteractionCompleter } from './interaction/util/InteractionCompleter';
@ -118,33 +120,40 @@ export class IdentityProviderHttpHandler extends HttpHandler {
* Finds the matching route and resolves the request. * Finds the matching route and resolves the request.
*/ */
private async handleRequest(request: HttpRequest, response: HttpResponse): Promise<void> { private async handleRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
// 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);
} catch {
// Just a regular request
}
// If our own interaction handler does not support the input, it is either invalid or a request for the OIDC library // If our own interaction handler does not support the input, it is either invalid or a request for the OIDC library
const route = await this.findRoute(request, response); const route = await this.findRoute(request, oidcInteraction);
if (!route) { if (!route) {
const provider = await this.providerFactory.getProvider(); const provider = await this.providerFactory.getProvider();
this.logger.debug(`Sending request to oidc-provider: ${request.url}`); this.logger.debug(`Sending request to oidc-provider: ${request.url}`);
return provider.callback(request, response); return provider.callback(request, response);
} }
await this.resolveRoute(request, response, route); await this.resolveRoute(request, response, route, oidcInteraction);
} }
/** /**
* Finds a route that supports the given request. * Finds a route that supports the given request.
*/ */
private async findRoute(request: HttpRequest, response: HttpResponse): Promise<InteractionRoute | undefined> { private async findRoute(request: HttpRequest, oidcInteraction?: Interaction): Promise<InteractionRoute | undefined> {
if (!request.url || !request.url.startsWith(this.idpPath)) { if (!request.url || !request.url.startsWith(this.idpPath)) {
// This is either an invalid request or a call to the .well-known configuration // This is either an invalid request or a call to the .well-known configuration
return; return;
} }
const url = request.url.slice(this.idpPath.length); const pathName = request.url.slice(this.idpPath.length);
let route = this.getRouteMatch(url); let route = this.getRouteMatch(pathName);
// In case the request targets the IDP entry point the prompt determines where to go // In case the request targets the IDP entry point the prompt determines where to go
if (!route && (url === '/' || url === '')) { if (!route && oidcInteraction && trimTrailingSlashes(pathName).length === 0) {
const provider = await this.providerFactory.getProvider(); route = this.getPromptMatch(oidcInteraction.prompt.name);
const interactionDetails = await provider.interactionDetails(request, response);
route = this.getPromptMatch(interactionDetails.prompt.name);
} }
return route; return route;
} }
@ -155,7 +164,8 @@ export class IdentityProviderHttpHandler extends HttpHandler {
* *
* GET requests go to the templateHandler, POST requests to the specific InteractionHandler of the route. * GET requests go to the templateHandler, POST requests to the specific InteractionHandler of the route.
*/ */
private async resolveRoute(request: HttpRequest, response: HttpResponse, route: InteractionRoute): Promise<void> { private async resolveRoute(request: HttpRequest, response: HttpResponse, route: InteractionRoute,
oidcInteraction?: Interaction): Promise<void> {
if (request.method === 'GET') { if (request.method === 'GET') {
// .ejs templates errors on undefined variables // .ejs templates errors on undefined variables
return await this.handleTemplateResponse(response, route.viewTemplate, { errorMessage: '', prefilled: {}}); return await this.handleTemplateResponse(response, route.viewTemplate, { errorMessage: '', prefilled: {}});
@ -164,7 +174,7 @@ export class IdentityProviderHttpHandler extends HttpHandler {
if (request.method === 'POST') { if (request.method === 'POST') {
let result: InteractionHandlerResult; let result: InteractionHandlerResult;
try { try {
result = await route.handler.handleSafe({ request, response }); result = await route.handler.handleSafe({ request, oidcInteraction });
} catch (error: unknown) { } catch (error: unknown) {
// Render error in the view // Render error in the view
const prefilled = IdpInteractionError.isInstance(error) ? error.prefilled : {}; const prefilled = IdpInteractionError.isInstance(error) ? error.prefilled : {};

View File

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

View File

@ -1,7 +1,6 @@
import assert from 'assert'; import assert from 'assert';
import urljoin from 'url-join'; import urljoin from 'url-join';
import { getLoggerFor } from '../../../../logging/LogUtil'; import { getLoggerFor } from '../../../../logging/LogUtil';
import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import { ensureTrailingSlash } from '../../../../util/PathUtil'; import { ensureTrailingSlash } from '../../../../util/PathUtil';
import type { TemplateEngine } from '../../../../util/templates/TemplateEngine'; import type { TemplateEngine } from '../../../../util/templates/TemplateEngine';
import type { EmailSender } from '../../util/EmailSender'; import type { EmailSender } from '../../util/EmailSender';
@ -9,7 +8,7 @@ import { getFormDataRequestBody } from '../../util/FormDataUtil';
import { throwIdpInteractionError } from '../EmailPasswordUtil'; import { throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore'; import type { AccountStore } from '../storage/AccountStore';
import { InteractionHandler } from './InteractionHandler'; import { InteractionHandler } from './InteractionHandler';
import type { InteractionResponseResult } from './InteractionHandler'; import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler';
export interface ForgotPasswordHandlerArgs { export interface ForgotPasswordHandlerArgs {
accountStore: AccountStore; accountStore: AccountStore;
@ -40,10 +39,10 @@ export class ForgotPasswordHandler extends InteractionHandler {
this.emailSender = args.emailSender; this.emailSender = args.emailSender;
} }
public async handle(input: HttpHandlerInput): Promise<InteractionResponseResult<{ email: string }>> { public async handle({ request }: InteractionHandlerInput): Promise<InteractionResponseResult<{ email: string }>> {
try { try {
// Validate incoming data // Validate incoming data
const { email } = await getFormDataRequestBody(input.request); const { email } = await getFormDataRequestBody(request);
assert(typeof email === 'string' && email.length > 0, 'Email required'); assert(typeof email === 'string' && email.length > 0, 'Email required');
await this.resetPassword(email); await this.resetPassword(email);

View File

@ -1,7 +1,23 @@
import type { HttpHandlerInput } from '../../../../server/HttpHandler'; import type { KoaContextWithOIDC } from 'oidc-provider';
import type { HttpRequest } from '../../../../server/HttpRequest';
import { AsyncHandler } from '../../../../util/handlers/AsyncHandler'; import { AsyncHandler } from '../../../../util/handlers/AsyncHandler';
import type { InteractionCompleterParams } from '../../util/InteractionCompleter'; import type { InteractionCompleterParams } from '../../util/InteractionCompleter';
// OIDC library does not directly export the Interaction type
export type Interaction = KoaContextWithOIDC['oidc']['entities']['Interaction'];
export interface InteractionHandlerInput {
/**
* The request being made.
*/
request: HttpRequest;
/**
* Will be defined if the OIDC library expects us to resolve an interaction it can't handle itself,
* such as logging a user in.
*/
oidcInteraction?: Interaction;
}
export type InteractionHandlerResult = InteractionResponseResult | InteractionCompleteResult; export type InteractionHandlerResult = InteractionResponseResult | InteractionCompleteResult;
export interface InteractionResponseResult<T = NodeJS.Dict<any>> { export interface InteractionResponseResult<T = NodeJS.Dict<any>> {
@ -17,4 +33,4 @@ export interface InteractionCompleteResult {
/** /**
* Handler used for IDP interactions. * Handler used for IDP interactions.
*/ */
export abstract class InteractionHandler extends AsyncHandler<HttpHandlerInput, InteractionHandlerResult> {} export abstract class InteractionHandler extends AsyncHandler<InteractionHandlerInput, InteractionHandlerResult> {}

View File

@ -1,12 +1,11 @@
import assert from 'assert'; import assert from 'assert';
import { getLoggerFor } from '../../../../logging/LogUtil'; import { getLoggerFor } from '../../../../logging/LogUtil';
import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import type { HttpRequest } from '../../../../server/HttpRequest'; import type { HttpRequest } from '../../../../server/HttpRequest';
import { getFormDataRequestBody } from '../../util/FormDataUtil'; import { getFormDataRequestBody } from '../../util/FormDataUtil';
import { throwIdpInteractionError } from '../EmailPasswordUtil'; import { throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore'; import type { AccountStore } from '../storage/AccountStore';
import { InteractionHandler } from './InteractionHandler'; import { InteractionHandler } from './InteractionHandler';
import type { InteractionCompleteResult } from './InteractionHandler'; import type { InteractionCompleteResult, InteractionHandlerInput } from './InteractionHandler';
/** /**
* Handles the submission of the Login Form and logs the user in. * Handles the submission of the Login Form and logs the user in.
@ -21,8 +20,8 @@ export class LoginHandler extends InteractionHandler {
this.accountStore = accountStore; this.accountStore = accountStore;
} }
public async handle(input: HttpHandlerInput): Promise<InteractionCompleteResult> { public async handle({ request }: InteractionHandlerInput): Promise<InteractionCompleteResult> {
const { email, password, remember } = await this.parseInput(input.request); const { email, password, remember } = await this.parseInput(request);
try { try {
// Try to log in, will error if email/password combination is invalid // Try to log in, will error if email/password combination is invalid
const webId = await this.accountStore.authenticate(email, password); const webId = await this.accountStore.authenticate(email, password);

View File

@ -5,13 +5,12 @@ import { getLoggerFor } from '../../../../logging/LogUtil';
import type { IdentifierGenerator } from '../../../../pods/generate/IdentifierGenerator'; import type { IdentifierGenerator } from '../../../../pods/generate/IdentifierGenerator';
import type { PodManager } from '../../../../pods/PodManager'; import type { PodManager } from '../../../../pods/PodManager';
import type { PodSettings } from '../../../../pods/settings/PodSettings'; import type { PodSettings } from '../../../../pods/settings/PodSettings';
import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import type { HttpRequest } from '../../../../server/HttpRequest'; import type { HttpRequest } from '../../../../server/HttpRequest';
import type { OwnershipValidator } from '../../../ownership/OwnershipValidator'; import type { OwnershipValidator } from '../../../ownership/OwnershipValidator';
import { getFormDataRequestBody } from '../../util/FormDataUtil'; import { getFormDataRequestBody } from '../../util/FormDataUtil';
import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil'; import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore'; import type { AccountStore } from '../storage/AccountStore';
import type { InteractionResponseResult } from './InteractionHandler'; import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler';
import { InteractionHandler } from './InteractionHandler'; import { InteractionHandler } from './InteractionHandler';
const emailRegex = /^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/u; const emailRegex = /^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/u;
@ -103,7 +102,7 @@ export class RegistrationHandler extends InteractionHandler {
this.podManager = args.podManager; this.podManager = args.podManager;
} }
public async handle({ request }: HttpHandlerInput): Promise<InteractionResponseResult<RegistrationResponse>> { public async handle({ request }: InteractionHandlerInput): Promise<InteractionResponseResult<RegistrationResponse>> {
const result = await this.parseInput(request); const result = await this.parseInput(request);
try { try {

View File

@ -1,10 +1,9 @@
import assert from 'assert'; import assert from 'assert';
import { getLoggerFor } from '../../../../logging/LogUtil'; import { getLoggerFor } from '../../../../logging/LogUtil';
import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import { getFormDataRequestBody } from '../../util/FormDataUtil'; import { getFormDataRequestBody } from '../../util/FormDataUtil';
import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil'; import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore'; import type { AccountStore } from '../storage/AccountStore';
import type { InteractionResponseResult } from './InteractionHandler'; import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler';
import { InteractionHandler } from './InteractionHandler'; import { InteractionHandler } from './InteractionHandler';
/** /**
@ -21,12 +20,12 @@ export class ResetPasswordHandler extends InteractionHandler {
this.accountStore = accountStore; this.accountStore = accountStore;
} }
public async handle(input: HttpHandlerInput): Promise<InteractionResponseResult> { public async handle({ request }: InteractionHandlerInput): Promise<InteractionResponseResult> {
try { try {
// Extract record ID from request URL // Extract record ID from request URL
const recordId = /\/([^/]+)$/u.exec(input.request.url!)?.[1]; const recordId = /\/([^/]+)$/u.exec(request.url!)?.[1];
// Validate input data // Validate input data
const { password, confirmPassword } = await getFormDataRequestBody(input.request); const { password, confirmPassword } = await getFormDataRequestBody(request);
assert( assert(
typeof recordId === 'string' && recordId.length > 0, typeof recordId === 'string' && recordId.length > 0,
'Invalid request. Open the link from your email again', 'Invalid request. Open the link from your email again',

View File

@ -93,7 +93,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
request.method = 'POST'; request.method = 'POST';
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1); expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1);
expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ request, response }); expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ request });
expect(templateHandler.handleSafe).toHaveBeenCalledTimes(1); expect(templateHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(templateHandler.handleSafe).toHaveBeenLastCalledWith( expect(templateHandler.handleSafe).toHaveBeenLastCalledWith(
{ response, templateFile: routes.response.responseTemplate, contents: { key: 'val' }}, { response, templateFile: routes.response.responseTemplate, contents: { key: 'val' }},
@ -106,7 +106,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
(routes.response.handler as jest.Mocked<InteractionHandler>).handleSafe.mockResolvedValueOnce({ type: 'response' }); (routes.response.handler as jest.Mocked<InteractionHandler>).handleSafe.mockResolvedValueOnce({ type: 'response' });
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1); expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1);
expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ request, response }); expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ request });
expect(templateHandler.handleSafe).toHaveBeenCalledTimes(1); expect(templateHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(templateHandler.handleSafe).toHaveBeenLastCalledWith( expect(templateHandler.handleSafe).toHaveBeenLastCalledWith(
{ response, templateFile: routes.response.responseTemplate, contents: {}}, { response, templateFile: routes.response.responseTemplate, contents: {}},
@ -118,7 +118,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
request.method = 'POST'; request.method = 'POST';
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1); expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1);
expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ request, response }); expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ request });
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1);
expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ request, response, webId: 'webId' }); expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ request, response, webId: 'webId' });
}); });
@ -126,18 +126,22 @@ describe('An IdentityProviderHttpHandler', (): void => {
it('matches paths based on prompt for requests to the root IDP.', async(): Promise<void> => { it('matches paths based on prompt for requests to the root IDP.', async(): Promise<void> => {
request.url = '/idp'; request.url = '/idp';
request.method = 'POST'; request.method = 'POST';
provider.interactionDetails.mockResolvedValueOnce({ prompt: { name: 'other' }} as any); const oidcInteraction = { prompt: { name: 'other' }};
provider.interactionDetails.mockResolvedValueOnce(oidcInteraction as any);
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(0); expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(0);
expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1); expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1);
expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ request, oidcInteraction });
}); });
it('uses the default route for requests to the root IDP without (matching) prompt.', async(): Promise<void> => { it('uses the default route for requests to the root IDP without (matching) prompt.', async(): Promise<void> => {
request.url = '/idp'; request.url = '/idp';
request.method = 'POST'; request.method = 'POST';
provider.interactionDetails.mockResolvedValueOnce({ prompt: { name: 'notSupported' }} as any); const oidcInteraction = { prompt: { name: 'notSupported' }};
provider.interactionDetails.mockResolvedValueOnce(oidcInteraction as any);
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1); expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1);
expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ request, oidcInteraction });
expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(0); expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(0);
}); });

View File

@ -1,45 +1,31 @@
import type { Provider } from 'oidc-provider'; import type { Interaction } from '../../../../src/identity/interaction/email-password/handler/InteractionHandler';
import type { ProviderFactory } from '../../../../src/identity/configuration/ProviderFactory';
import { SessionHttpHandler } from '../../../../src/identity/interaction/SessionHttpHandler'; import { SessionHttpHandler } from '../../../../src/identity/interaction/SessionHttpHandler';
import type { HttpRequest } from '../../../../src/server/HttpRequest'; import type { HttpRequest } from '../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../src/server/HttpResponse';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('A SessionHttpHandler', (): void => { describe('A SessionHttpHandler', (): void => {
const request: HttpRequest = {} as any; const request: HttpRequest = {} as any;
const response: HttpResponse = {} as any;
const webId = 'http://test.com/id#me'; const webId = 'http://test.com/id#me';
let details: any = {}; let oidcInteraction: Interaction;
let provider: Provider;
let handler: SessionHttpHandler; let handler: SessionHttpHandler;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
details = { session: { accountId: webId }}; oidcInteraction = { session: { accountId: webId }} as any;
provider = {
interactionDetails: jest.fn().mockResolvedValue(details),
} as any;
const factory: ProviderFactory = { handler = new SessionHttpHandler();
getProvider: jest.fn().mockResolvedValue(provider),
};
handler = new SessionHttpHandler(factory);
}); });
it('requires a session and accountId.', async(): Promise<void> => { it('requires a defined oidcInteraction with a session.', async(): Promise<void> => {
details.session = undefined; oidcInteraction!.session = undefined;
await expect(handler.handle({ request, response })).rejects.toThrow(NotImplementedHttpError); await expect(handler.handle({ request, oidcInteraction })).rejects.toThrow(NotImplementedHttpError);
details.session = { accountId: undefined }; await expect(handler.handle({ request })).rejects.toThrow(NotImplementedHttpError);
await expect(handler.handle({ request, response })).rejects.toThrow(NotImplementedHttpError);
}); });
it('calls the oidc completer with the webId in the session.', async(): Promise<void> => { it('returns an InteractionCompleteResult when done.', async(): Promise<void> => {
await expect(handler.handle({ request, response })).resolves.toEqual({ await expect(handler.handle({ request, oidcInteraction })).resolves.toEqual({
details: { webId }, details: { webId },
type: 'complete', type: 'complete',
}); });
expect(provider.interactionDetails).toHaveBeenCalledTimes(1);
expect(provider.interactionDetails).toHaveBeenLastCalledWith(request, response);
}); });
}); });

View File

@ -4,13 +4,11 @@ import {
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender'; import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender';
import type { HttpRequest } from '../../../../../../src/server/HttpRequest'; import type { HttpRequest } from '../../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../../src/server/HttpResponse';
import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine'; import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine';
import { createPostFormRequest } from './Util'; import { createPostFormRequest } from './Util';
describe('A ForgotPasswordHandler', (): void => { describe('A ForgotPasswordHandler', (): void => {
let request: HttpRequest; let request: HttpRequest;
const response: HttpResponse = {} as any;
const email = 'test@test.email'; const email = 'test@test.email';
const recordId = '123456'; const recordId = '123456';
const html = `<a href="/base/idp/resetpassword/${recordId}">Reset Password</a>`; const html = `<a href="/base/idp/resetpassword/${recordId}">Reset Password</a>`;
@ -47,20 +45,20 @@ describe('A ForgotPasswordHandler', (): void => {
it('errors on non-string emails.', async(): Promise<void> => { it('errors on non-string emails.', async(): Promise<void> => {
request = createPostFormRequest({}); request = createPostFormRequest({});
await expect(handler.handle({ request, response })).rejects.toThrow('Email required'); await expect(handler.handle({ request })).rejects.toThrow('Email required');
request = createPostFormRequest({ email: [ 'email', 'email2' ]}); request = createPostFormRequest({ email: [ 'email', 'email2' ]});
await expect(handler.handle({ request, response })).rejects.toThrow('Email required'); await expect(handler.handle({ request })).rejects.toThrow('Email required');
}); });
it('does not send a mail if a ForgotPassword record could not be generated.', async(): Promise<void> => { it('does not send a mail if a ForgotPassword record could not be generated.', async(): Promise<void> => {
(accountStore.generateForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('error'); (accountStore.generateForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('error');
await expect(handler.handle({ request, response })).resolves await expect(handler.handle({ request })).resolves
.toEqual({ type: 'response', details: { email }}); .toEqual({ type: 'response', details: { email }});
expect(emailSender.handleSafe).toHaveBeenCalledTimes(0); expect(emailSender.handleSafe).toHaveBeenCalledTimes(0);
}); });
it('sends a mail if a ForgotPassword record could be generated.', async(): Promise<void> => { it('sends a mail if a ForgotPassword record could be generated.', async(): Promise<void> => {
await expect(handler.handle({ request, response })).resolves await expect(handler.handle({ request })).resolves
.toEqual({ type: 'response', details: { email }}); .toEqual({ type: 'response', details: { email }});
expect(emailSender.handleSafe).toHaveBeenCalledTimes(1); expect(emailSender.handleSafe).toHaveBeenCalledTimes(1);
expect(emailSender.handleSafe).toHaveBeenLastCalledWith({ expect(emailSender.handleSafe).toHaveBeenLastCalledWith({

View File

@ -1,12 +1,14 @@
import type {
InteractionHandlerInput,
} from '../../../../../../src/identity/interaction/email-password/handler/InteractionHandler';
import { LoginHandler } from '../../../../../../src/identity/interaction/email-password/handler/LoginHandler'; import { LoginHandler } from '../../../../../../src/identity/interaction/email-password/handler/LoginHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { HttpHandlerInput } from '../../../../../../src/server/HttpHandler';
import { createPostFormRequest } from './Util'; import { createPostFormRequest } from './Util';
describe('A LoginHandler', (): void => { describe('A LoginHandler', (): void => {
const webId = 'http://alice.test.com/card#me'; const webId = 'http://alice.test.com/card#me';
const email = 'alice@test.email'; const email = 'alice@test.email';
let input: HttpHandlerInput; let input: InteractionHandlerInput;
let storageAdapter: AccountStore; let storageAdapter: AccountStore;
let handler: LoginHandler; let handler: LoginHandler;

View File

@ -10,7 +10,6 @@ import type { IdentifierGenerator } from '../../../../../../src/pods/generate/Id
import type { PodManager } from '../../../../../../src/pods/PodManager'; import type { PodManager } from '../../../../../../src/pods/PodManager';
import type { PodSettings } from '../../../../../../src/pods/settings/PodSettings'; import type { PodSettings } from '../../../../../../src/pods/settings/PodSettings';
import type { HttpRequest } from '../../../../../../src/server/HttpRequest'; import type { HttpRequest } from '../../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../../src/server/HttpResponse';
import { createPostFormRequest } from './Util'; import { createPostFormRequest } from './Util';
describe('A RegistrationHandler', (): void => { describe('A RegistrationHandler', (): void => {
@ -27,7 +26,6 @@ describe('A RegistrationHandler', (): void => {
const createPod = 'true'; const createPod = 'true';
let request: HttpRequest; let request: HttpRequest;
const response: HttpResponse = {} as any;
const baseUrl = 'http://test.com/'; const baseUrl = 'http://test.com/';
const webIdSuffix = '/profile/card'; const webIdSuffix = '/profile/card';
@ -72,71 +70,71 @@ describe('A RegistrationHandler', (): void => {
describe('validating data', (): void => { describe('validating data', (): void => {
it('rejects array inputs.', async(): Promise<void> => { it('rejects array inputs.', async(): Promise<void> => {
request = createPostFormRequest({ mydata: [ 'a', 'b' ]}); request = createPostFormRequest({ mydata: [ 'a', 'b' ]});
await expect(handler.handle({ request, response })) await expect(handler.handle({ request }))
.rejects.toThrow('Unexpected multiple values for mydata.'); .rejects.toThrow('Unexpected multiple values for mydata.');
}); });
it('errors on invalid emails.', async(): Promise<void> => { it('errors on invalid emails.', async(): Promise<void> => {
request = createPostFormRequest({ email: undefined }); request = createPostFormRequest({ email: undefined });
await expect(handler.handle({ request, response })) await expect(handler.handle({ request }))
.rejects.toThrow('Please enter a valid e-mail address.'); .rejects.toThrow('Please enter a valid e-mail address.');
request = createPostFormRequest({ email: '' }); request = createPostFormRequest({ email: '' });
await expect(handler.handle({ request, response })) await expect(handler.handle({ request }))
.rejects.toThrow('Please enter a valid e-mail address.'); .rejects.toThrow('Please enter a valid e-mail address.');
request = createPostFormRequest({ email: 'invalidEmail' }); request = createPostFormRequest({ email: 'invalidEmail' });
await expect(handler.handle({ request, response })) await expect(handler.handle({ request }))
.rejects.toThrow('Please enter a valid e-mail address.'); .rejects.toThrow('Please enter a valid e-mail address.');
}); });
it('errors when a required WebID is not valid.', async(): Promise<void> => { it('errors when a required WebID is not valid.', async(): Promise<void> => {
request = createPostFormRequest({ email, register, webId: undefined }); request = createPostFormRequest({ email, register, webId: undefined });
await expect(handler.handle({ request, response })) await expect(handler.handle({ request }))
.rejects.toThrow('Please enter a valid WebID.'); .rejects.toThrow('Please enter a valid WebID.');
request = createPostFormRequest({ email, register, webId: '' }); request = createPostFormRequest({ email, register, webId: '' });
await expect(handler.handle({ request, response })) await expect(handler.handle({ request }))
.rejects.toThrow('Please enter a valid WebID.'); .rejects.toThrow('Please enter a valid WebID.');
}); });
it('errors on invalid passwords when registering.', async(): Promise<void> => { it('errors on invalid passwords when registering.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, password, confirmPassword: 'bad', register }); request = createPostFormRequest({ email, webId, password, confirmPassword: 'bad', register });
await expect(handler.handle({ request, response })) await expect(handler.handle({ request }))
.rejects.toThrow('Your password and confirmation did not match.'); .rejects.toThrow('Your password and confirmation did not match.');
}); });
it('errors on invalid pod names when required.', async(): Promise<void> => { it('errors on invalid pod names when required.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, createPod, podName: undefined }); request = createPostFormRequest({ email, webId, createPod, podName: undefined });
await expect(handler.handle({ request, response })) await expect(handler.handle({ request }))
.rejects.toThrow('Please specify a Pod name.'); .rejects.toThrow('Please specify a Pod name.');
request = createPostFormRequest({ email, webId, createPod, podName: ' ' }); request = createPostFormRequest({ email, webId, createPod, podName: ' ' });
await expect(handler.handle({ request, response })) await expect(handler.handle({ request }))
.rejects.toThrow('Please specify a Pod name.'); .rejects.toThrow('Please specify a Pod name.');
request = createPostFormRequest({ email, webId, createWebId }); request = createPostFormRequest({ email, webId, createWebId });
await expect(handler.handle({ request, response })) await expect(handler.handle({ request }))
.rejects.toThrow('Please specify a Pod name.'); .rejects.toThrow('Please specify a Pod name.');
}); });
it('errors when trying to create a WebID without registering or creating a pod.', async(): Promise<void> => { it('errors when trying to create a WebID without registering or creating a pod.', async(): Promise<void> => {
request = createPostFormRequest({ email, podName, createWebId }); request = createPostFormRequest({ email, podName, createWebId });
await expect(handler.handle({ request, response })) await expect(handler.handle({ request }))
.rejects.toThrow('Please enter a password.'); .rejects.toThrow('Please enter a password.');
request = createPostFormRequest({ email, podName, createWebId, createPod }); request = createPostFormRequest({ email, podName, createWebId, createPod });
await expect(handler.handle({ request, response })) await expect(handler.handle({ request }))
.rejects.toThrow('Please enter a password.'); .rejects.toThrow('Please enter a password.');
request = createPostFormRequest({ email, podName, createWebId, createPod, register }); request = createPostFormRequest({ email, podName, createWebId, createPod, register });
await expect(handler.handle({ request, response })) await expect(handler.handle({ request }))
.rejects.toThrow('Please enter a password.'); .rejects.toThrow('Please enter a password.');
}); });
it('errors when no option is chosen.', async(): Promise<void> => { it('errors when no option is chosen.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId }); request = createPostFormRequest({ email, webId });
await expect(handler.handle({ request, response })) await expect(handler.handle({ request }))
.rejects.toThrow('Please register for a WebID or create a Pod.'); .rejects.toThrow('Please register for a WebID or create a Pod.');
}); });
}); });
@ -144,7 +142,7 @@ describe('A RegistrationHandler', (): void => {
describe('handling data', (): void => { describe('handling data', (): void => {
it('can register a user.', async(): Promise<void> => { it('can register a user.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, password, confirmPassword, register }); request = createPostFormRequest({ email, webId, password, confirmPassword, register });
await expect(handler.handle({ request, response })).resolves.toEqual({ await expect(handler.handle({ request })).resolves.toEqual({
details: { details: {
email, email,
webId, webId,
@ -171,7 +169,7 @@ describe('A RegistrationHandler', (): void => {
it('can create a pod.', async(): Promise<void> => { it('can create a pod.', async(): Promise<void> => {
const params = { email, webId, podName, createPod }; const params = { email, webId, podName, createPod };
request = createPostFormRequest(params); request = createPostFormRequest(params);
await expect(handler.handle({ request, response })).resolves.toEqual({ await expect(handler.handle({ request })).resolves.toEqual({
details: { details: {
email, email,
webId, webId,
@ -200,7 +198,7 @@ describe('A RegistrationHandler', (): void => {
const params = { email, webId, password, confirmPassword, podName, register, createPod }; const params = { email, webId, password, confirmPassword, podName, register, createPod };
podSettings.oidcIssuer = baseUrl; podSettings.oidcIssuer = baseUrl;
request = createPostFormRequest(params); request = createPostFormRequest(params);
await expect(handler.handle({ request, response })).resolves.toEqual({ await expect(handler.handle({ request })).resolves.toEqual({
details: { details: {
email, email,
webId, webId,
@ -232,7 +230,7 @@ describe('A RegistrationHandler', (): void => {
podSettings.oidcIssuer = baseUrl; podSettings.oidcIssuer = baseUrl;
request = createPostFormRequest(params); request = createPostFormRequest(params);
(podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error')); (podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error'));
await expect(handler.handle({ request, response })).rejects.toThrow('pod error'); await expect(handler.handle({ request })).rejects.toThrow('pod error');
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
@ -255,7 +253,7 @@ describe('A RegistrationHandler', (): void => {
podSettings.oidcIssuer = baseUrl; podSettings.oidcIssuer = baseUrl;
request = createPostFormRequest(params); request = createPostFormRequest(params);
await expect(handler.handle({ request, response })).resolves.toEqual({ await expect(handler.handle({ request })).resolves.toEqual({
details: { details: {
email, email,
webId: generatedWebID, webId: generatedWebID,
@ -285,7 +283,7 @@ describe('A RegistrationHandler', (): void => {
const params = { email, webId, podName, createPod }; const params = { email, webId, podName, createPod };
request = createPostFormRequest(params); request = createPostFormRequest(params);
(podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error')); (podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error'));
const prom = handler.handle({ request, response }); const prom = handler.handle({ request });
await expect(prom).rejects.toThrow('pod error'); await expect(prom).rejects.toThrow('pod error');
await expect(prom).rejects.toThrow(IdpInteractionError); await expect(prom).rejects.toThrow(IdpInteractionError);
// Using the cleaned input for prefilled // Using the cleaned input for prefilled

View File

@ -3,12 +3,10 @@ import {
} from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordHandler'; } from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { HttpRequest } from '../../../../../../src/server/HttpRequest'; import type { HttpRequest } from '../../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../../src/server/HttpResponse';
import { createPostFormRequest } from './Util'; import { createPostFormRequest } from './Util';
describe('A ResetPasswordHandler', (): void => { describe('A ResetPasswordHandler', (): void => {
let request: HttpRequest; let request: HttpRequest;
const response: HttpResponse = {} as any;
const recordId = '123456'; const recordId = '123456';
const url = `/resetURL/${recordId}`; const url = `/resetURL/${recordId}`;
const email = 'alice@test.email'; const email = 'alice@test.email';
@ -28,27 +26,27 @@ describe('A ResetPasswordHandler', (): void => {
it('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'; const errorMessage = 'Invalid request. Open the link from your email again';
request = createPostFormRequest({}); request = createPostFormRequest({});
await expect(handler.handle({ request, response })).rejects.toThrow(errorMessage); await expect(handler.handle({ request })).rejects.toThrow(errorMessage);
request = createPostFormRequest({}, ''); request = createPostFormRequest({}, '');
await expect(handler.handle({ request, response })).rejects.toThrow(errorMessage); await expect(handler.handle({ request })).rejects.toThrow(errorMessage);
}); });
it('errors for invalid passwords.', async(): Promise<void> => { it('errors for invalid passwords.', async(): Promise<void> => {
const errorMessage = 'Your password and confirmation did not match.'; const errorMessage = 'Your password and confirmation did not match.';
request = createPostFormRequest({ password: 'password!', confirmPassword: 'otherPassword!' }, url); request = createPostFormRequest({ password: 'password!', confirmPassword: 'otherPassword!' }, url);
await expect(handler.handle({ request, response })).rejects.toThrow(errorMessage); await expect(handler.handle({ request })).rejects.toThrow(errorMessage);
}); });
it('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.'; const errorMessage = 'This reset password link is no longer valid.';
request = createPostFormRequest({ password: 'password!', confirmPassword: 'password!' }, url); request = createPostFormRequest({ password: 'password!', confirmPassword: 'password!' }, url);
(accountStore.getForgotPasswordRecord as jest.Mock).mockResolvedValueOnce(undefined); (accountStore.getForgotPasswordRecord as jest.Mock).mockResolvedValueOnce(undefined);
await expect(handler.handle({ request, response })).rejects.toThrow(errorMessage); await expect(handler.handle({ request })).rejects.toThrow(errorMessage);
}); });
it('renders a message on success.', async(): Promise<void> => { it('renders a message on success.', async(): Promise<void> => {
request = createPostFormRequest({ password: 'password!', confirmPassword: 'password!' }, url); request = createPostFormRequest({ password: 'password!', confirmPassword: 'password!' }, url);
await expect(handler.handle({ request, response })).resolves.toEqual({ type: 'response' }); await expect(handler.handle({ request })).resolves.toEqual({ type: 'response' });
expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1); expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1);
expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId); expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId);
expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1); expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1);
@ -61,6 +59,6 @@ describe('A ResetPasswordHandler', (): void => {
const errorMessage = 'Unknown error: not native'; const errorMessage = 'Unknown error: not native';
request = createPostFormRequest({ password: 'password!', confirmPassword: 'password!' }, url); request = createPostFormRequest({ password: 'password!', confirmPassword: 'password!' }, url);
(accountStore.getForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('not native'); (accountStore.getForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('not native');
await expect(handler.handle({ request, response })).rejects.toThrow(errorMessage); await expect(handler.handle({ request })).rejects.toThrow(errorMessage);
}); });
}); });