mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
refactor: Move InteractionRoute behaviour to separate class
This commit is contained in:
parent
3542fe29da
commit
bbfbfbbce4
@ -4,19 +4,19 @@
|
||||
{
|
||||
"comment": "Handles all functionality on the forgot password page",
|
||||
"@id": "urn:solid-server:auth:password:ForgotPasswordRoute",
|
||||
"@type": "InteractionRoute",
|
||||
"@type": "BasicInteractionRoute",
|
||||
"route": "^/forgotpassword/?$",
|
||||
"viewTemplates": {
|
||||
"InteractionRoute:_viewTemplates_key": "text/html",
|
||||
"InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/forgot-password.html.ejs"
|
||||
"BasicInteractionRoute:_viewTemplates_key": "text/html",
|
||||
"BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/forgot-password.html.ejs"
|
||||
},
|
||||
"responseTemplates": {
|
||||
"InteractionRoute:_responseTemplates_key": "text/html",
|
||||
"InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/forgot-password-response.html.ejs"
|
||||
"BasicInteractionRoute:_responseTemplates_key": "text/html",
|
||||
"BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/forgot-password-response.html.ejs"
|
||||
},
|
||||
"controls": {
|
||||
"InteractionRoute:_controls_key": "forgotPassword",
|
||||
"InteractionRoute:_controls_value": "/forgotpassword"
|
||||
"BasicInteractionRoute:_controls_key": "forgotPassword",
|
||||
"BasicInteractionRoute:_controls_value": "/forgotpassword"
|
||||
},
|
||||
"handler": {
|
||||
"@type": "ForgotPasswordHandler",
|
||||
|
@ -4,16 +4,16 @@
|
||||
{
|
||||
"comment": "Handles all functionality on the Login Page",
|
||||
"@id": "urn:solid-server:auth:password:LoginRoute",
|
||||
"@type": "InteractionRoute",
|
||||
"@type": "BasicInteractionRoute",
|
||||
"route": "^/login/?$",
|
||||
"prompt": "login",
|
||||
"viewTemplates": {
|
||||
"InteractionRoute:_viewTemplates_key": "text/html",
|
||||
"InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/login.html.ejs"
|
||||
"BasicInteractionRoute:_viewTemplates_key": "text/html",
|
||||
"BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/login.html.ejs"
|
||||
},
|
||||
"controls": {
|
||||
"InteractionRoute:_controls_key": "login",
|
||||
"InteractionRoute:_controls_value": "/login"
|
||||
"BasicInteractionRoute:_controls_key": "login",
|
||||
"BasicInteractionRoute:_controls_value": "/login"
|
||||
},
|
||||
"handler": {
|
||||
"@type": "LoginHandler",
|
||||
|
@ -5,15 +5,15 @@
|
||||
{
|
||||
"comment": "Handles the reset password page submission",
|
||||
"@id": "urn:solid-server:auth:password:ResetPasswordRoute",
|
||||
"@type": "InteractionRoute",
|
||||
"@type": "BasicInteractionRoute",
|
||||
"route": "^/resetpassword(/[^/]*)?$",
|
||||
"viewTemplates": {
|
||||
"InteractionRoute:_viewTemplates_key": "text/html",
|
||||
"InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/reset-password.html.ejs"
|
||||
"BasicInteractionRoute:_viewTemplates_key": "text/html",
|
||||
"BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/reset-password.html.ejs"
|
||||
},
|
||||
"responseTemplates": {
|
||||
"InteractionRoute:_responseTemplates_key": "text/html",
|
||||
"InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/reset-password-response.html.ejs"
|
||||
"BasicInteractionRoute:_responseTemplates_key": "text/html",
|
||||
"BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/reset-password-response.html.ejs"
|
||||
},
|
||||
"handler": {
|
||||
"@type": "ResetPasswordHandler",
|
||||
|
@ -4,12 +4,12 @@
|
||||
{
|
||||
"comment": "Handles confirm requests",
|
||||
"@id": "urn:solid-server:auth:password:SessionRoute",
|
||||
"@type": "InteractionRoute",
|
||||
"@type": "BasicInteractionRoute",
|
||||
"route": "^/confirm/?$",
|
||||
"prompt": "consent",
|
||||
"viewTemplates": {
|
||||
"InteractionRoute:_viewTemplates_key": "text/html",
|
||||
"InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/confirm.html.ejs"
|
||||
"BasicInteractionRoute:_viewTemplates_key": "text/html",
|
||||
"BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/confirm.html.ejs"
|
||||
},
|
||||
"handler": { "@type": "SessionHttpHandler" }
|
||||
}
|
||||
|
@ -4,19 +4,19 @@
|
||||
{
|
||||
"comment": "Handles all functionality on the register page",
|
||||
"@id": "urn:solid-server:auth:password:RegistrationRoute",
|
||||
"@type": "InteractionRoute",
|
||||
"@type": "BasicInteractionRoute",
|
||||
"route": "^/register/?$",
|
||||
"viewTemplates": {
|
||||
"InteractionRoute:_viewTemplates_key": "text/html",
|
||||
"InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/register.html.ejs"
|
||||
"BasicInteractionRoute:_viewTemplates_key": "text/html",
|
||||
"BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/register.html.ejs"
|
||||
},
|
||||
"responseTemplates": {
|
||||
"InteractionRoute:_responseTemplates_key": "text/html",
|
||||
"InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/register-response.html.ejs"
|
||||
"BasicInteractionRoute:_responseTemplates_key": "text/html",
|
||||
"BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/register-response.html.ejs"
|
||||
},
|
||||
"controls": {
|
||||
"InteractionRoute:_controls_key": "register",
|
||||
"InteractionRoute:_controls_value": "/register"
|
||||
"BasicInteractionRoute:_controls_key": "register",
|
||||
"BasicInteractionRoute:_controls_value": "/register"
|
||||
},
|
||||
"handler": {
|
||||
"@type": "RegistrationHandler",
|
||||
|
@ -14,59 +14,16 @@ import type { HttpResponse } from '../server/HttpResponse';
|
||||
import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter';
|
||||
import { APPLICATION_JSON } from '../util/ContentTypes';
|
||||
import { BadRequestHttpError } from '../util/errors/BadRequestHttpError';
|
||||
import { createErrorMessage } from '../util/errors/ErrorUtil';
|
||||
import { joinUrl, trimTrailingSlashes } from '../util/PathUtil';
|
||||
import { addTemplateMetadata } from '../util/ResourceUtil';
|
||||
import type { ProviderFactory } from './configuration/ProviderFactory';
|
||||
import type {
|
||||
Interaction,
|
||||
InteractionHandler,
|
||||
InteractionHandlerResult,
|
||||
InteractionResponseResult,
|
||||
} from './interaction/email-password/handler/InteractionHandler';
|
||||
import { IdpInteractionError } from './interaction/util/IdpInteractionError';
|
||||
import type { Interaction } from './interaction/email-password/handler/InteractionHandler';
|
||||
import type { InteractionRoute, TemplatedInteractionResult } from './interaction/routing/InteractionRoute';
|
||||
import type { InteractionCompleter } from './interaction/util/InteractionCompleter';
|
||||
|
||||
// Registration is not standardized within Solid yet, so we use a custom versioned API for now
|
||||
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 {
|
||||
// Workaround for https://github.com/LinkedSoftwareDependencies/Components-Generator.js/issues/73
|
||||
requestParser: RequestParser;
|
||||
@ -147,6 +104,7 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
|
||||
let oidcInteraction: Interaction | undefined;
|
||||
try {
|
||||
const provider = await this.providerFactory.getProvider();
|
||||
// This being defined means we're in an OIDC session
|
||||
oidcInteraction = await provider.interactionDetails(request, response);
|
||||
} catch {
|
||||
// Just a regular request
|
||||
@ -175,8 +133,8 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
|
||||
operation.body = await this.converter.handleSafe(args);
|
||||
}
|
||||
|
||||
const { result, templateFiles } = await this.resolveRoute(operation, route, oidcInteraction);
|
||||
return this.handleInteractionResult(operation, request, result, templateFiles, oidcInteraction);
|
||||
const result = await route.handleOperation(operation, 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);
|
||||
|
||||
// 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) {
|
||||
if (checkPrompt) {
|
||||
if (route.prompt === oidcInteraction!.prompt.name) {
|
||||
return route;
|
||||
}
|
||||
} else if (route.route.test(pathName)) {
|
||||
if (route.supportsPath(pathName, oidcInteraction?.prompt.name)) {
|
||||
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.
|
||||
* 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,
|
||||
templateFiles: Record<string, string>, oidcInteraction?: Interaction): Promise<ResponseDescription> {
|
||||
private async handleInteractionResult(operation: Operation, request: HttpRequest,
|
||||
result: TemplatedInteractionResult, oidcInteraction?: Interaction): Promise<ResponseDescription> {
|
||||
let responseDescription: ResponseDescription | undefined;
|
||||
|
||||
if (result.type === 'complete') {
|
||||
@ -254,7 +174,7 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
|
||||
responseDescription = new RedirectResponseDescription(location);
|
||||
} else {
|
||||
// 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;
|
||||
@ -264,8 +184,8 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
|
||||
* Converts an InteractionResponseResult to a ResponseDescription by first converting to a Representation
|
||||
* and applying necessary conversions.
|
||||
*/
|
||||
private async handleResponseResult(result: InteractionResponseResult, templateFiles: Record<string, string>,
|
||||
operation: Operation, oidcInteraction?: Interaction): Promise<ResponseDescription> {
|
||||
private async handleResponseResult(result: TemplatedInteractionResult, operation: Operation,
|
||||
oidcInteraction?: Interaction): Promise<ResponseDescription> {
|
||||
// Convert the object to a valid JSON representation
|
||||
const json = {
|
||||
apiVersion: API_VERSION,
|
||||
@ -276,7 +196,7 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
|
||||
const representation = new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON);
|
||||
|
||||
// Template metadata is required for conversion
|
||||
for (const [ type, templateFile ] of Object.entries(templateFiles)) {
|
||||
for (const [ type, templateFile ] of Object.entries(result.templateFiles)) {
|
||||
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.
|
||||
*/
|
||||
private getRouteControls(route: InteractionRoute): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(route.controls).map(([ name, path ]): [ string, string ] => [ name, joinUrl(this.baseUrl, path) ]),
|
||||
);
|
||||
const entries = Object.entries(route.getControls())
|
||||
.map(([ name, path ]): [ string, string ] => [ name, joinUrl(this.baseUrl, path) ]);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
}
|
||||
|
100
src/identity/interaction/routing/BasicInteractionRoute.ts
Normal file
100
src/identity/interaction/routing/BasicInteractionRoute.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
33
src/identity/interaction/routing/InteractionRoute.ts
Normal file
33
src/identity/interaction/routing/InteractionRoute.ts
Normal 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>;
|
||||
}
|
@ -41,6 +41,10 @@ export * from './identity/interaction/email-password/storage/BaseAccountStore';
|
||||
// Identity/Interaction/Email-Password
|
||||
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
|
||||
export * from './identity/interaction/util/BaseEmailSender';
|
||||
export * from './identity/interaction/util/EmailSender';
|
||||
|
@ -1,14 +1,8 @@
|
||||
import type { Provider } from 'oidc-provider';
|
||||
import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory';
|
||||
import type {
|
||||
IdentityProviderHttpHandlerArgs,
|
||||
} from '../../../src/identity/IdentityProviderHttpHandler';
|
||||
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 { IdentityProviderHttpHandlerArgs } from '../../../src/identity/IdentityProviderHttpHandler';
|
||||
import { IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler';
|
||||
import type { InteractionRoute } from '../../../src/identity/interaction/routing/InteractionRoute';
|
||||
import type { InteractionCompleter } from '../../../src/identity/interaction/util/InteractionCompleter';
|
||||
import type { ErrorHandler } from '../../../src/ldp/http/ErrorHandler';
|
||||
import type { RequestParser } from '../../../src/ldp/http/RequestParser';
|
||||
@ -24,7 +18,6 @@ import type {
|
||||
RepresentationConverter,
|
||||
RepresentationConverterArgs,
|
||||
} from '../../../src/storage/conversion/RepresentationConverter';
|
||||
import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpError';
|
||||
import { joinUrl } from '../../../src/util/PathUtil';
|
||||
import { readableToString } from '../../../src/util/StreamUtil';
|
||||
import { CONTENT_TYPE, SOLID_HTTP, SOLID_META } from '../../../src/util/Vocabularies';
|
||||
@ -37,7 +30,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
const response: HttpResponse = {} as any;
|
||||
let requestParser: jest.Mocked<RequestParser>;
|
||||
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 interactionCompleter: jest.Mocked<InteractionCompleter>;
|
||||
let converter: jest.Mocked<RepresentationConverter>;
|
||||
@ -69,22 +62,25 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
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 = {
|
||||
response: new InteractionRoute('^/routeResponse$',
|
||||
{ 'text/html': '/view1' },
|
||||
handlers[0],
|
||||
'login',
|
||||
{ 'text/html': '/response1' },
|
||||
{ response: '/routeResponse' }),
|
||||
complete: new InteractionRoute('^/routeComplete$',
|
||||
{ 'text/html': '/view2' },
|
||||
handlers[1],
|
||||
'other'),
|
||||
response: {
|
||||
getControls: jest.fn().mockReturnValue({ response: '/routeResponse' }),
|
||||
supportsPath: jest.fn((path: string): boolean => /^\/routeResponse$/u.test(path)),
|
||||
handleOperation: jest.fn().mockResolvedValue({
|
||||
type: 'response',
|
||||
details: { key: 'val' },
|
||||
templateFiles: { 'text/html': '/response' },
|
||||
}),
|
||||
},
|
||||
complete: {
|
||||
getControls: jest.fn().mockReturnValue({}),
|
||||
supportsPath: jest.fn((path: string): boolean => /^\/routeComplete$/u.test(path)),
|
||||
handleOperation: jest.fn().mockResolvedValue({
|
||||
type: 'complete',
|
||||
details: { webId: 'webId' },
|
||||
templateFiles: {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
controls = { response: 'http://test.com/idp/routeResponse' };
|
||||
|
||||
@ -124,27 +120,13 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
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> => {
|
||||
request.url = '/idp/routeResponse';
|
||||
request.method = 'POST';
|
||||
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
|
||||
const operation: Operation = await requestParser.handleSafe.mock.results[0].value;
|
||||
expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ operation });
|
||||
expect(routes.response.handleOperation).toHaveBeenCalledTimes(1);
|
||||
expect(routes.response.handleOperation).toHaveBeenLastCalledWith(operation, undefined);
|
||||
expect(operation.body?.metadata.contentType).toBe('application/json');
|
||||
|
||||
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
|
||||
@ -154,7 +136,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
.toEqual({ apiVersion, key: 'val', 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.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> => {
|
||||
@ -162,7 +144,8 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
request.method = 'POST';
|
||||
const oidcInteraction = { session: { accountId: 'account' }, prompt: {}} as any;
|
||||
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();
|
||||
|
||||
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
|
||||
@ -176,8 +159,8 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
errorHandler.handleSafe.mockResolvedValueOnce({ statusCode: 400 });
|
||||
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
|
||||
const operation: Operation = await requestParser.handleSafe.mock.results[0].value;
|
||||
expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation });
|
||||
expect(routes.complete.handleOperation).toHaveBeenCalledTimes(1);
|
||||
expect(routes.complete.handleOperation).toHaveBeenLastCalledWith(operation, undefined);
|
||||
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(0);
|
||||
expect(operation.body?.metadata.contentType).toBe('application/json');
|
||||
|
||||
@ -198,8 +181,8 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
provider.interactionDetails.mockResolvedValueOnce(oidcInteraction);
|
||||
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
|
||||
const operation: Operation = await requestParser.handleSafe.mock.results[0].value;
|
||||
expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction });
|
||||
expect(routes.complete.handleOperation).toHaveBeenCalledTimes(1);
|
||||
expect(routes.complete.handleOperation).toHaveBeenLastCalledWith(operation, oidcInteraction);
|
||||
expect(operation.body?.metadata.contentType).toBe('application/json');
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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> => {
|
||||
request.url = '/idp/routeResponse';
|
||||
request.method = 'GET';
|
||||
@ -268,16 +207,4 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
|
||||
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 }});
|
||||
});
|
||||
});
|
||||
|
@ -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: {}});
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user