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