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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user