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:
@@ -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);
|
||||
operation = {
|
||||
...operation,
|
||||
body: await this.converter.handleSafe(args),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
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.
|
||||
* Default implementation of an InteractionHandler with an InteractionRoute.
|
||||
*
|
||||
* Rejects operations that target a different path,
|
||||
* otherwise the input parameters get passed to the source handler.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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>;
|
||||
export class BasicInteractionRoute extends InteractionHandler implements InteractionRoute {
|
||||
private readonly path: string;
|
||||
private readonly source: InteractionHandler;
|
||||
|
||||
/**
|
||||
* @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;
|
||||
public constructor(path: string, source?: InteractionHandler) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.source = source ?? new UnsupportedAsyncHandler('This route has no associated handler.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored controls.
|
||||
*/
|
||||
public getControls(): Record<string, string> {
|
||||
return this.controls;
|
||||
public getPath(): string {
|
||||
return this.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
public async canHandle(input: InteractionHandlerInput): Promise<void> {
|
||||
const { target } = input.operation;
|
||||
const path = this.getPath();
|
||||
if (target.path !== path) {
|
||||
throw new NotFoundHttpError();
|
||||
}
|
||||
return this.route.test(path);
|
||||
await this.source.canHandle(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET requests return a default response result.
|
||||
* POST requests return the InteractionHandler result.
|
||||
* InteractionHandler errors will be converted into response results.
|
||||
*
|
||||
* All results will be appended with the matching template paths.
|
||||
*
|
||||
* Will error for other methods
|
||||
*/
|
||||
public async handleOperation(operation: Operation, oidcInteraction?: Interaction):
|
||||
Promise<TemplatedInteractionResult> {
|
||||
switch (operation.method) {
|
||||
case 'GET':
|
||||
return { type: 'response', templateFiles: this.viewTemplates };
|
||||
case 'POST':
|
||||
try {
|
||||
const result = await this.handler.handleSafe({ operation, oidcInteraction });
|
||||
return { ...result, templateFiles: this.responseTemplates };
|
||||
} catch (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;
|
||||
}
|
||||
const error = isError(err) ? err : new InternalServerError(createErrorMessage(err));
|
||||
// Potentially render the error in the view
|
||||
return { type: 'error', error, templateFiles: this.viewTemplates };
|
||||
}
|
||||
default:
|
||||
throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`);
|
||||
}
|
||||
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';
|
||||
|
||||
Reference in New Issue
Block a user