refactor: Move InteractionRoute behaviour to separate class

This commit is contained in:
Joachim Van Herwegen 2021-08-26 11:59:52 +02:00
parent 3542fe29da
commit bbfbfbbce4
11 changed files with 296 additions and 226 deletions

View File

@ -4,19 +4,19 @@
{ {
"comment": "Handles all functionality on the forgot password page", "comment": "Handles all functionality on the forgot password page",
"@id": "urn:solid-server:auth:password:ForgotPasswordRoute", "@id": "urn:solid-server:auth:password:ForgotPasswordRoute",
"@type": "InteractionRoute", "@type": "BasicInteractionRoute",
"route": "^/forgotpassword/?$", "route": "^/forgotpassword/?$",
"viewTemplates": { "viewTemplates": {
"InteractionRoute:_viewTemplates_key": "text/html", "BasicInteractionRoute:_viewTemplates_key": "text/html",
"InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/forgot-password.html.ejs" "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/forgot-password.html.ejs"
}, },
"responseTemplates": { "responseTemplates": {
"InteractionRoute:_responseTemplates_key": "text/html", "BasicInteractionRoute:_responseTemplates_key": "text/html",
"InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/forgot-password-response.html.ejs" "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/forgot-password-response.html.ejs"
}, },
"controls": { "controls": {
"InteractionRoute:_controls_key": "forgotPassword", "BasicInteractionRoute:_controls_key": "forgotPassword",
"InteractionRoute:_controls_value": "/forgotpassword" "BasicInteractionRoute:_controls_value": "/forgotpassword"
}, },
"handler": { "handler": {
"@type": "ForgotPasswordHandler", "@type": "ForgotPasswordHandler",

View File

@ -4,16 +4,16 @@
{ {
"comment": "Handles all functionality on the Login Page", "comment": "Handles all functionality on the Login Page",
"@id": "urn:solid-server:auth:password:LoginRoute", "@id": "urn:solid-server:auth:password:LoginRoute",
"@type": "InteractionRoute", "@type": "BasicInteractionRoute",
"route": "^/login/?$", "route": "^/login/?$",
"prompt": "login", "prompt": "login",
"viewTemplates": { "viewTemplates": {
"InteractionRoute:_viewTemplates_key": "text/html", "BasicInteractionRoute:_viewTemplates_key": "text/html",
"InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/login.html.ejs" "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/login.html.ejs"
}, },
"controls": { "controls": {
"InteractionRoute:_controls_key": "login", "BasicInteractionRoute:_controls_key": "login",
"InteractionRoute:_controls_value": "/login" "BasicInteractionRoute:_controls_value": "/login"
}, },
"handler": { "handler": {
"@type": "LoginHandler", "@type": "LoginHandler",

View File

@ -5,15 +5,15 @@
{ {
"comment": "Handles the reset password page submission", "comment": "Handles the reset password page submission",
"@id": "urn:solid-server:auth:password:ResetPasswordRoute", "@id": "urn:solid-server:auth:password:ResetPasswordRoute",
"@type": "InteractionRoute", "@type": "BasicInteractionRoute",
"route": "^/resetpassword(/[^/]*)?$", "route": "^/resetpassword(/[^/]*)?$",
"viewTemplates": { "viewTemplates": {
"InteractionRoute:_viewTemplates_key": "text/html", "BasicInteractionRoute:_viewTemplates_key": "text/html",
"InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/reset-password.html.ejs" "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/reset-password.html.ejs"
}, },
"responseTemplates": { "responseTemplates": {
"InteractionRoute:_responseTemplates_key": "text/html", "BasicInteractionRoute:_responseTemplates_key": "text/html",
"InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/reset-password-response.html.ejs" "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/reset-password-response.html.ejs"
}, },
"handler": { "handler": {
"@type": "ResetPasswordHandler", "@type": "ResetPasswordHandler",

View File

@ -4,12 +4,12 @@
{ {
"comment": "Handles confirm requests", "comment": "Handles confirm requests",
"@id": "urn:solid-server:auth:password:SessionRoute", "@id": "urn:solid-server:auth:password:SessionRoute",
"@type": "InteractionRoute", "@type": "BasicInteractionRoute",
"route": "^/confirm/?$", "route": "^/confirm/?$",
"prompt": "consent", "prompt": "consent",
"viewTemplates": { "viewTemplates": {
"InteractionRoute:_viewTemplates_key": "text/html", "BasicInteractionRoute:_viewTemplates_key": "text/html",
"InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/confirm.html.ejs" "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/confirm.html.ejs"
}, },
"handler": { "@type": "SessionHttpHandler" } "handler": { "@type": "SessionHttpHandler" }
} }

View File

@ -4,19 +4,19 @@
{ {
"comment": "Handles all functionality on the register page", "comment": "Handles all functionality on the register page",
"@id": "urn:solid-server:auth:password:RegistrationRoute", "@id": "urn:solid-server:auth:password:RegistrationRoute",
"@type": "InteractionRoute", "@type": "BasicInteractionRoute",
"route": "^/register/?$", "route": "^/register/?$",
"viewTemplates": { "viewTemplates": {
"InteractionRoute:_viewTemplates_key": "text/html", "BasicInteractionRoute:_viewTemplates_key": "text/html",
"InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/register.html.ejs" "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/register.html.ejs"
}, },
"responseTemplates": { "responseTemplates": {
"InteractionRoute:_responseTemplates_key": "text/html", "BasicInteractionRoute:_responseTemplates_key": "text/html",
"InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/register-response.html.ejs" "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/register-response.html.ejs"
}, },
"controls": { "controls": {
"InteractionRoute:_controls_key": "register", "BasicInteractionRoute:_controls_key": "register",
"InteractionRoute:_controls_value": "/register" "BasicInteractionRoute:_controls_value": "/register"
}, },
"handler": { "handler": {
"@type": "RegistrationHandler", "@type": "RegistrationHandler",

View File

@ -14,59 +14,16 @@ import type { HttpResponse } from '../server/HttpResponse';
import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter'; import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter';
import { APPLICATION_JSON } from '../util/ContentTypes'; import { APPLICATION_JSON } from '../util/ContentTypes';
import { BadRequestHttpError } from '../util/errors/BadRequestHttpError'; import { BadRequestHttpError } from '../util/errors/BadRequestHttpError';
import { createErrorMessage } from '../util/errors/ErrorUtil';
import { joinUrl, trimTrailingSlashes } from '../util/PathUtil'; import { joinUrl, trimTrailingSlashes } from '../util/PathUtil';
import { addTemplateMetadata } from '../util/ResourceUtil'; import { addTemplateMetadata } from '../util/ResourceUtil';
import type { ProviderFactory } from './configuration/ProviderFactory'; import type { ProviderFactory } from './configuration/ProviderFactory';
import type { import type { Interaction } from './interaction/email-password/handler/InteractionHandler';
Interaction, import type { InteractionRoute, TemplatedInteractionResult } from './interaction/routing/InteractionRoute';
InteractionHandler,
InteractionHandlerResult,
InteractionResponseResult,
} from './interaction/email-password/handler/InteractionHandler';
import { IdpInteractionError } from './interaction/util/IdpInteractionError';
import type { InteractionCompleter } from './interaction/util/InteractionCompleter'; import type { InteractionCompleter } from './interaction/util/InteractionCompleter';
// Registration is not standardized within Solid yet, so we use a custom versioned API for now // Registration is not standardized within Solid yet, so we use a custom versioned API for now
const API_VERSION = '0.2'; const API_VERSION = '0.2';
/**
* All the information that is required to handle a request to a custom IDP path.
*/
export class InteractionRoute {
public readonly route: RegExp;
public readonly handler: InteractionHandler;
public readonly viewTemplates: Record<string, string>;
public readonly prompt?: string;
public readonly responseTemplates: Record<string, string>;
public readonly controls: Record<string, string>;
/**
* @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<string, string>,
handler: InteractionHandler,
prompt?: string,
responseTemplates: Record<string, string> = {},
controls: Record<string, string> = {}) {
this.route = new RegExp(route, 'u');
this.viewTemplates = viewTemplates;
this.handler = handler;
this.prompt = prompt;
this.responseTemplates = responseTemplates;
this.controls = controls;
}
}
export interface IdentityProviderHttpHandlerArgs extends BaseHttpHandlerArgs { export interface IdentityProviderHttpHandlerArgs extends BaseHttpHandlerArgs {
// Workaround for https://github.com/LinkedSoftwareDependencies/Components-Generator.js/issues/73 // Workaround for https://github.com/LinkedSoftwareDependencies/Components-Generator.js/issues/73
requestParser: RequestParser; requestParser: RequestParser;
@ -147,6 +104,7 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
let oidcInteraction: Interaction | undefined; let oidcInteraction: Interaction | undefined;
try { try {
const provider = await this.providerFactory.getProvider(); const provider = await this.providerFactory.getProvider();
// This being defined means we're in an OIDC session
oidcInteraction = await provider.interactionDetails(request, response); oidcInteraction = await provider.interactionDetails(request, response);
} catch { } catch {
// Just a regular request // Just a regular request
@ -175,8 +133,8 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
operation.body = await this.converter.handleSafe(args); operation.body = await this.converter.handleSafe(args);
} }
const { result, templateFiles } = await this.resolveRoute(operation, route, oidcInteraction); const result = await route.handleOperation(operation, oidcInteraction);
return this.handleInteractionResult(operation, request, result, templateFiles, oidcInteraction); return this.handleInteractionResult(operation, request, result, oidcInteraction);
} }
/** /**
@ -189,57 +147,19 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
} }
const pathName = operation.target.path.slice(this.baseUrl.length); const pathName = operation.target.path.slice(this.baseUrl.length);
// In case the request targets the IDP entry point the prompt determines where to go
const checkPrompt = oidcInteraction && trimTrailingSlashes(pathName).length === 0;
for (const route of this.interactionRoutes) { for (const route of this.interactionRoutes) {
if (checkPrompt) { if (route.supportsPath(pathName, oidcInteraction?.prompt.name)) {
if (route.prompt === oidcInteraction!.prompt.name) {
return route;
}
} else if (route.route.test(pathName)) {
return route; return route;
} }
} }
} }
/**
* Handles the behaviour of an InteractionRoute.
* Will error if the route does not support the given request.
*
* GET requests return a default response result,
* POST requests to the specific InteractionHandler of the route.
*/
private async resolveRoute(operation: Operation, route: InteractionRoute, oidcInteraction?: Interaction):
Promise<{ result: InteractionHandlerResult; templateFiles: Record<string, string> }> {
if (operation.method === 'GET') {
return { result: { type: 'response' }, templateFiles: route.viewTemplates };
}
if (operation.method === 'POST') {
try {
const result = await route.handler.handleSafe({ operation, oidcInteraction });
return { result, templateFiles: route.responseTemplates };
} catch (error: unknown) {
// Render error in the view
const errorMessage = createErrorMessage(error);
const result: InteractionResponseResult = { type: 'response', details: { errorMessage }};
if (IdpInteractionError.isInstance(error)) {
result.details!.prefilled = error.prefilled;
}
return { result, templateFiles: route.viewTemplates };
}
}
throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`);
}
/** /**
* Creates a ResponseDescription based on the InteractionHandlerResult. * 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". * This will either be a redirect if type is "complete" or a data stream if the type is "response".
*/ */
private async handleInteractionResult(operation: Operation, request: HttpRequest, result: InteractionHandlerResult, private async handleInteractionResult(operation: Operation, request: HttpRequest,
templateFiles: Record<string, string>, oidcInteraction?: Interaction): Promise<ResponseDescription> { result: TemplatedInteractionResult, oidcInteraction?: Interaction): Promise<ResponseDescription> {
let responseDescription: ResponseDescription | undefined; let responseDescription: ResponseDescription | undefined;
if (result.type === 'complete') { if (result.type === 'complete') {
@ -254,7 +174,7 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
responseDescription = new RedirectResponseDescription(location); responseDescription = new RedirectResponseDescription(location);
} else { } else {
// Convert the response object to a data stream // Convert the response object to a data stream
responseDescription = await this.handleResponseResult(result, templateFiles, operation, oidcInteraction); responseDescription = await this.handleResponseResult(result, operation, oidcInteraction);
} }
return responseDescription; return responseDescription;
@ -264,8 +184,8 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
* Converts an InteractionResponseResult to a ResponseDescription by first converting to a Representation * Converts an InteractionResponseResult to a ResponseDescription by first converting to a Representation
* and applying necessary conversions. * and applying necessary conversions.
*/ */
private async handleResponseResult(result: InteractionResponseResult, templateFiles: Record<string, string>, private async handleResponseResult(result: TemplatedInteractionResult, operation: Operation,
operation: Operation, oidcInteraction?: Interaction): Promise<ResponseDescription> { oidcInteraction?: Interaction): Promise<ResponseDescription> {
// Convert the object to a valid JSON representation // Convert the object to a valid JSON representation
const json = { const json = {
apiVersion: API_VERSION, apiVersion: API_VERSION,
@ -276,7 +196,7 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
const representation = new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON); const representation = new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON);
// Template metadata is required for conversion // Template metadata is required for conversion
for (const [ type, templateFile ] of Object.entries(templateFiles)) { for (const [ type, templateFile ] of Object.entries(result.templateFiles)) {
addTemplateMetadata(representation.metadata, templateFile, type); addTemplateMetadata(representation.metadata, templateFile, type);
} }
@ -291,8 +211,8 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
* Converts the controls object of a route to one with full URLs. * Converts the controls object of a route to one with full URLs.
*/ */
private getRouteControls(route: InteractionRoute): Record<string, string> { private getRouteControls(route: InteractionRoute): Record<string, string> {
return Object.fromEntries( const entries = Object.entries(route.getControls())
Object.entries(route.controls).map(([ name, path ]): [ string, string ] => [ name, joinUrl(this.baseUrl, path) ]), .map(([ name, path ]): [ string, string ] => [ name, joinUrl(this.baseUrl, path) ]);
); return Object.fromEntries(entries);
} }
} }

View File

@ -0,0 +1,100 @@
import type { Operation } from '../../../ldp/operations/Operation';
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
import { createErrorMessage } from '../../../util/errors/ErrorUtil';
import { trimTrailingSlashes } from '../../../util/PathUtil';
import type {
InteractionResponseResult,
InteractionHandler,
Interaction,
} from '../email-password/handler/InteractionHandler';
import { IdpInteractionError } from '../util/IdpInteractionError';
import type { InteractionRoute, TemplatedInteractionResult } from './InteractionRoute';
/**
* Default implementation of the InteractionRoute.
* See function comments for specifics.
*/
export class BasicInteractionRoute implements InteractionRoute {
public readonly route: RegExp;
public readonly handler: InteractionHandler;
public readonly viewTemplates: Record<string, string>;
public readonly prompt?: string;
public readonly responseTemplates: Record<string, string>;
public readonly controls: Record<string, string>;
/**
* @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<string, string>,
handler: InteractionHandler,
prompt?: string,
responseTemplates: Record<string, string> = {},
controls: Record<string, string> = {}) {
this.route = new RegExp(route, 'u');
this.viewTemplates = viewTemplates;
this.handler = handler;
this.prompt = prompt;
this.responseTemplates = responseTemplates;
this.controls = controls;
}
/**
* Returns the stored controls.
*/
public getControls(): Record<string, string> {
return this.controls;
}
/**
* 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;
}
return this.route.test(path);
}
/**
* 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<TemplatedInteractionResult> {
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 (error: unknown) {
// Render error in the view
const errorMessage = createErrorMessage(error);
const result: InteractionResponseResult = { type: 'response', details: { errorMessage }};
if (IdpInteractionError.isInstance(error)) {
result.details!.prefilled = error.prefilled;
}
return { ...result, templateFiles: this.viewTemplates };
}
default:
throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`);
}
}
}

View File

@ -0,0 +1,33 @@
import type { Operation } from '../../../ldp/operations/Operation';
import type { Interaction, InteractionHandlerResult } from '../email-password/handler/InteractionHandler';
export type TemplatedInteractionResult = InteractionHandlerResult & {
templateFiles: Record<string, string>;
};
/**
* Handles the routing behaviour for IDP handlers.
*/
export interface InteractionRoute {
/**
* Returns the control fields that should be added to response objects.
* Keys are control names, values are relative URL paths.
*/
getControls: () => Record<string, string>;
/**
* 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<TemplatedInteractionResult>;
}

View File

@ -41,6 +41,10 @@ export * from './identity/interaction/email-password/storage/BaseAccountStore';
// Identity/Interaction/Email-Password // Identity/Interaction/Email-Password
export * from './identity/interaction/email-password/EmailPasswordUtil'; export * from './identity/interaction/email-password/EmailPasswordUtil';
// Identity/Interaction/Routing
export * from './identity/interaction/routing/BasicInteractionRoute';
export * from './identity/interaction/routing/InteractionRoute';
// Identity/Interaction/Util // Identity/Interaction/Util
export * from './identity/interaction/util/BaseEmailSender'; export * from './identity/interaction/util/BaseEmailSender';
export * from './identity/interaction/util/EmailSender'; export * from './identity/interaction/util/EmailSender';

View File

@ -1,14 +1,8 @@
import type { Provider } from 'oidc-provider'; import type { Provider } from 'oidc-provider';
import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory'; import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory';
import type { import type { IdentityProviderHttpHandlerArgs } from '../../../src/identity/IdentityProviderHttpHandler';
IdentityProviderHttpHandlerArgs, import { IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler';
} from '../../../src/identity/IdentityProviderHttpHandler'; import type { InteractionRoute } from '../../../src/identity/interaction/routing/InteractionRoute';
import {
InteractionRoute,
IdentityProviderHttpHandler,
} from '../../../src/identity/IdentityProviderHttpHandler';
import type { InteractionHandler } from '../../../src/identity/interaction/email-password/handler/InteractionHandler';
import { IdpInteractionError } from '../../../src/identity/interaction/util/IdpInteractionError';
import type { InteractionCompleter } from '../../../src/identity/interaction/util/InteractionCompleter'; import type { InteractionCompleter } from '../../../src/identity/interaction/util/InteractionCompleter';
import type { ErrorHandler } from '../../../src/ldp/http/ErrorHandler'; import type { ErrorHandler } from '../../../src/ldp/http/ErrorHandler';
import type { RequestParser } from '../../../src/ldp/http/RequestParser'; import type { RequestParser } from '../../../src/ldp/http/RequestParser';
@ -24,7 +18,6 @@ import type {
RepresentationConverter, RepresentationConverter,
RepresentationConverterArgs, RepresentationConverterArgs,
} from '../../../src/storage/conversion/RepresentationConverter'; } from '../../../src/storage/conversion/RepresentationConverter';
import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpError';
import { joinUrl } from '../../../src/util/PathUtil'; import { joinUrl } from '../../../src/util/PathUtil';
import { readableToString } from '../../../src/util/StreamUtil'; import { readableToString } from '../../../src/util/StreamUtil';
import { CONTENT_TYPE, SOLID_HTTP, SOLID_META } from '../../../src/util/Vocabularies'; import { CONTENT_TYPE, SOLID_HTTP, SOLID_META } from '../../../src/util/Vocabularies';
@ -37,7 +30,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
const response: HttpResponse = {} as any; const response: HttpResponse = {} as any;
let requestParser: jest.Mocked<RequestParser>; let requestParser: jest.Mocked<RequestParser>;
let providerFactory: jest.Mocked<ProviderFactory>; let providerFactory: jest.Mocked<ProviderFactory>;
let routes: { response: InteractionRoute; complete: InteractionRoute }; let routes: { response: jest.Mocked<InteractionRoute>; complete: jest.Mocked<InteractionRoute> };
let controls: Record<string, string>; let controls: Record<string, string>;
let interactionCompleter: jest.Mocked<InteractionCompleter>; let interactionCompleter: jest.Mocked<InteractionCompleter>;
let converter: jest.Mocked<RepresentationConverter>; let converter: jest.Mocked<RepresentationConverter>;
@ -69,22 +62,25 @@ describe('An IdentityProviderHttpHandler', (): void => {
getProvider: jest.fn().mockResolvedValue(provider), getProvider: jest.fn().mockResolvedValue(provider),
}; };
const handlers: InteractionHandler[] = [
{ handleSafe: jest.fn().mockResolvedValue({ type: 'response', details: { key: 'val' }}) } as any,
{ handleSafe: jest.fn().mockResolvedValue({ type: 'complete', details: { webId: 'webId' }}) } as any,
];
routes = { routes = {
response: new InteractionRoute('^/routeResponse$', response: {
{ 'text/html': '/view1' }, getControls: jest.fn().mockReturnValue({ response: '/routeResponse' }),
handlers[0], supportsPath: jest.fn((path: string): boolean => /^\/routeResponse$/u.test(path)),
'login', handleOperation: jest.fn().mockResolvedValue({
{ 'text/html': '/response1' }, type: 'response',
{ response: '/routeResponse' }), details: { key: 'val' },
complete: new InteractionRoute('^/routeComplete$', templateFiles: { 'text/html': '/response' },
{ 'text/html': '/view2' }, }),
handlers[1], },
'other'), 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: {},
}),
},
}; };
controls = { response: 'http://test.com/idp/routeResponse' }; controls = { response: 'http://test.com/idp/routeResponse' };
@ -124,27 +120,13 @@ describe('An IdentityProviderHttpHandler', (): void => {
expect(provider.callback).toHaveBeenLastCalledWith(request, response); expect(provider.callback).toHaveBeenLastCalledWith(request, response);
}); });
it('creates default Representations for GET requests.', async(): Promise<void> => {
request.url = '/idp/routeResponse';
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
expect(mockResponse).toBe(response);
expect(JSON.parse(await readableToString(result.data!)))
.toEqual({ apiVersion, authenticating: false, controls });
expect(result.statusCode).toBe(200);
expect(result.metadata?.contentType).toBe('text/html');
expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.viewTemplates['text/html']);
});
it('creates Representations for InteractionResponseResults.', async(): Promise<void> => { it('creates Representations for InteractionResponseResults.', async(): Promise<void> => {
request.url = '/idp/routeResponse'; request.url = '/idp/routeResponse';
request.method = 'POST'; request.method = 'POST';
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves.toBeUndefined();
const operation: Operation = await requestParser.handleSafe.mock.results[0].value; const operation: Operation = await requestParser.handleSafe.mock.results[0].value;
expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1); expect(routes.response.handleOperation).toHaveBeenCalledTimes(1);
expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ operation }); expect(routes.response.handleOperation).toHaveBeenLastCalledWith(operation, undefined);
expect(operation.body?.metadata.contentType).toBe('application/json'); expect(operation.body?.metadata.contentType).toBe('application/json');
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
@ -154,7 +136,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
.toEqual({ apiVersion, key: 'val', authenticating: false, controls }); .toEqual({ apiVersion, key: 'val', authenticating: false, controls });
expect(result.statusCode).toBe(200); expect(result.statusCode).toBe(200);
expect(result.metadata?.contentType).toBe('text/html'); expect(result.metadata?.contentType).toBe('text/html');
expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.responseTemplates['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<void> => { it('indicates to the templates if the request is part of an auth flow.', async(): Promise<void> => {
@ -162,7 +144,8 @@ describe('An IdentityProviderHttpHandler', (): void => {
request.method = 'POST'; request.method = 'POST';
const oidcInteraction = { session: { accountId: 'account' }, prompt: {}} as any; const oidcInteraction = { session: { accountId: 'account' }, prompt: {}} as any;
provider.interactionDetails.mockResolvedValueOnce(oidcInteraction); provider.interactionDetails.mockResolvedValueOnce(oidcInteraction);
(routes.response.handler as jest.Mocked<InteractionHandler>).handleSafe.mockResolvedValueOnce({ type: 'response' }); routes.response.handleOperation
.mockResolvedValueOnce({ type: 'response', templateFiles: { 'text/html': '/response' }});
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
@ -176,8 +159,8 @@ describe('An IdentityProviderHttpHandler', (): void => {
errorHandler.handleSafe.mockResolvedValueOnce({ statusCode: 400 }); errorHandler.handleSafe.mockResolvedValueOnce({ statusCode: 400 });
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves.toBeUndefined();
const operation: Operation = await requestParser.handleSafe.mock.results[0].value; const operation: Operation = await requestParser.handleSafe.mock.results[0].value;
expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1); expect(routes.complete.handleOperation).toHaveBeenCalledTimes(1);
expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation }); expect(routes.complete.handleOperation).toHaveBeenLastCalledWith(operation, undefined);
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(0); expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(0);
expect(operation.body?.metadata.contentType).toBe('application/json'); expect(operation.body?.metadata.contentType).toBe('application/json');
@ -198,8 +181,8 @@ describe('An IdentityProviderHttpHandler', (): void => {
provider.interactionDetails.mockResolvedValueOnce(oidcInteraction); provider.interactionDetails.mockResolvedValueOnce(oidcInteraction);
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).resolves.toBeUndefined();
const operation: Operation = await requestParser.handleSafe.mock.results[0].value; const operation: Operation = await requestParser.handleSafe.mock.results[0].value;
expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1); expect(routes.complete.handleOperation).toHaveBeenCalledTimes(1);
expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); expect(routes.complete.handleOperation).toHaveBeenLastCalledWith(operation, oidcInteraction);
expect(operation.body?.metadata.contentType).toBe('application/json'); expect(operation.body?.metadata.contentType).toBe('application/json');
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1);
@ -212,50 +195,6 @@ describe('An IdentityProviderHttpHandler', (): void => {
expect(args.result.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(location); expect(args.result.metadata?.get(SOLID_HTTP.terms.location)?.value).toBe(location);
}); });
it('matches paths based on prompt for requests to the root IDP.', async(): Promise<void> => {
request.url = '/idp';
request.method = 'POST';
const oidcInteraction = { prompt: { name: 'other' }};
provider.interactionDetails.mockResolvedValueOnce(oidcInteraction as any);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
const operation: Operation = await requestParser.handleSafe.mock.results[0].value;
expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(0);
expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1);
expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction });
expect(operation.body?.metadata.contentType).toBe('application/json');
});
it('displays a viewTemplate again in case of POST errors.', async(): Promise<void> => {
request.url = '/idp/routeResponse';
request.method = 'POST';
(routes.response.handler.handleSafe as any)
.mockRejectedValueOnce(new IdpInteractionError(500, 'handle error', { name: 'name' }));
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
expect(mockResponse).toBe(response);
expect(JSON.parse(await readableToString(result.data!))).toEqual(
{ apiVersion, errorMessage: 'handle error', prefilled: { name: 'name' }, authenticating: false, controls },
);
expect(result.statusCode).toBe(200);
expect(result.metadata?.contentType).toBe('text/html');
expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.viewTemplates['text/html']);
});
it('defaults to an empty prefilled object in case of POST errors.', async(): Promise<void> => {
request.url = '/idp/routeResponse';
request.method = 'POST';
(routes.response.handler.handleSafe as any).mockRejectedValueOnce(new Error('handle error'));
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
expect(mockResponse).toBe(response);
expect(JSON.parse(await readableToString(result.data!)))
.toEqual({ apiVersion, errorMessage: 'handle error', authenticating: false, controls });
});
it('calls the errorHandler if there is a problem resolving the request.', async(): Promise<void> => { it('calls the errorHandler if there is a problem resolving the request.', async(): Promise<void> => {
request.url = '/idp/routeResponse'; request.url = '/idp/routeResponse';
request.method = 'GET'; request.method = 'GET';
@ -268,16 +207,4 @@ describe('An IdentityProviderHttpHandler', (): void => {
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }}); expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }});
}); });
it('can only resolve GET/POST requests.', async(): Promise<void> => {
request.url = '/idp/routeResponse';
request.method = 'DELETE';
const error = new BadRequestHttpError('Unsupported request: DELETE http://test.com/idp/routeResponse');
errorHandler.handleSafe.mockResolvedValueOnce({ statusCode: 500 });
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/html': 1 }}});
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }});
});
}); });

View File

@ -0,0 +1,86 @@
import type {
InteractionHandler,
} from '../../../../../src/identity/interaction/email-password/handler/InteractionHandler';
import { BasicInteractionRoute } from '../../../../../src/identity/interaction/routing/BasicInteractionRoute';
import { IdpInteractionError } from '../../../../../src/identity/interaction/util/IdpInteractionError';
import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError';
describe('A BasicInteractionRoute', (): void => {
const path = '^/route$';
const viewTemplates = { 'text/html': '/viewTemplate' };
let handler: jest.Mocked<InteractionHandler>;
const prompt = 'login';
const responseTemplates = { 'text/html': '/responseTemplate' };
const controls = { login: '/route' };
const response = { type: 'response' };
let route: BasicInteractionRoute;
beforeEach(async(): Promise<void> => {
handler = {
handleSafe: jest.fn().mockResolvedValue(response),
} as any;
route = new BasicInteractionRoute(path, viewTemplates, handler, prompt, responseTemplates, controls);
});
it('returns its controls.', async(): Promise<void> => {
expect(route.getControls()).toEqual(controls);
});
it('supports a path if it matches the stored route.', async(): Promise<void> => {
expect(route.supportsPath('/route')).toBe(true);
expect(route.supportsPath('/notRoute')).toBe(false);
});
it('supports prompts when targeting the base path.', async(): Promise<void> => {
expect(route.supportsPath('/', prompt)).toBe(true);
expect(route.supportsPath('/notRoute', prompt)).toBe(false);
expect(route.supportsPath('/', 'notPrompt')).toBe(false);
});
it('returns a response result on a GET request.', async(): Promise<void> => {
await expect(route.handleOperation({ method: 'GET' } as any))
.resolves.toEqual({ type: 'response', templateFiles: viewTemplates });
});
it('returns the result of the InteractionHandler on POST requests.', async(): Promise<void> => {
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('creates a response result in case the InteractionHandler errors.', async(): Promise<void> => {
const error = new Error('bad data');
handler.handleSafe.mockRejectedValueOnce(error);
await expect(route.handleOperation({ method: 'POST' } as any))
.resolves.toEqual({ type: 'response', details: { errorMessage: 'bad data' }, templateFiles: viewTemplates });
});
it('adds prefilled data in case the error is an IdpInteractionError.', async(): Promise<void> => {
const error = new IdpInteractionError(400, 'bad data', { name: 'Alice' });
handler.handleSafe.mockRejectedValueOnce(error);
await expect(route.handleOperation({ method: 'POST' } as any)).resolves.toEqual({
type: 'response',
details: { errorMessage: 'bad data', prefilled: { name: 'Alice' }},
templateFiles: viewTemplates,
});
});
it('errors for non-supported operations.', async(): Promise<void> => {
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<void> => {
route = new BasicInteractionRoute(path, viewTemplates, handler, prompt);
expect(route.getControls()).toEqual({});
});
it('defaults to empty response templates.', async(): Promise<void> => {
route = new BasicInteractionRoute(path, viewTemplates, handler, prompt);
await expect(route.handleOperation({ method: 'POST' } as any)).resolves.toEqual({ ...response, templateFiles: {}});
});
});