Skip to content

Commit

Permalink
feat: Support content negotiation for IDP requests
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Aug 18, 2021
1 parent 2aa410f commit 5de056e
Show file tree
Hide file tree
Showing 17 changed files with 383 additions and 204 deletions.
21 changes: 1 addition & 20 deletions config/identity/handler/default.json
Expand Up @@ -23,26 +23,7 @@
"idpPath": "/idp",
"requestParser": { "@id": "urn:solid-server:default:RequestParser" },
"providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" },
"templateHandler": {
"@type": "TemplateHandler",
"responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" },
"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 interaction 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"
}
]
}
},
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"interactionCompleter": {
"comment": "Responsible for finishing OIDC interactions.",
"@type": "InteractionCompleter",
Expand Down
10 changes: 8 additions & 2 deletions config/identity/handler/interaction/routes/forgot-password.json
Expand Up @@ -6,8 +6,14 @@
"@id": "urn:solid-server:auth:password:ForgotPasswordRoute",
"@type": "InteractionRoute",
"route": "^/forgotpassword/?$",
"viewTemplate": "@css:templates/identity/email-password/forgot-password.html.ejs",
"responseTemplate": "@css:templates/identity/email-password/forgot-password-response.html.ejs",
"viewTemplates": {
"InteractionRoute:_viewTemplates_key": "text/html",
"InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/forgot-password.html.ejs"
},
"responseTemplates": {
"InteractionRoute:_responseTemplates_key": "text/html",
"InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/forgot-password-response.html.ejs"
},
"handler": {
"@type": "ForgotPasswordHandler",
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
Expand Down
5 changes: 4 additions & 1 deletion config/identity/handler/interaction/routes/login.json
Expand Up @@ -7,7 +7,10 @@
"@type": "InteractionRoute",
"route": "^/login/?$",
"prompt": "default",
"viewTemplate": "@css:templates/identity/email-password/login.html.ejs",
"viewTemplates": {
"InteractionRoute:_viewTemplates_key": "text/html",
"InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/login.html.ejs"
},
"handler": {
"@type": "LoginHandler",
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }
Expand Down
10 changes: 8 additions & 2 deletions config/identity/handler/interaction/routes/reset-password.json
Expand Up @@ -7,8 +7,14 @@
"@id": "urn:solid-server:auth:password:ResetPasswordRoute",
"@type": "InteractionRoute",
"route": "^/resetpassword(/[^/]*)?$",
"viewTemplate": "@css:templates/identity/email-password/reset-password.html.ejs",
"responseTemplate": "@css:templates/identity/email-password/reset-password-response.html.ejs",
"viewTemplates": {
"InteractionRoute:_viewTemplates_key": "text/html",
"InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/reset-password.html.ejs"
},
"responseTemplates": {
"InteractionRoute:_responseTemplates_key": "text/html",
"InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/reset-password-response.html.ejs"
},
"handler": {
"@type": "ResetPasswordHandler",
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }
Expand Down
5 changes: 4 additions & 1 deletion config/identity/handler/interaction/routes/session.json
Expand Up @@ -7,7 +7,10 @@
"@type": "InteractionRoute",
"route": "^/confirm/?$",
"prompt": "consent",
"viewTemplate": "@css:templates/identity/email-password/confirm.html.ejs",
"viewTemplates": {
"InteractionRoute:_viewTemplates_key": "text/html",
"InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/confirm.html.ejs"
},
"handler": { "@type": "SessionHttpHandler" }
}
]
Expand Down
10 changes: 8 additions & 2 deletions config/identity/registration/route/registration.json
Expand Up @@ -6,8 +6,14 @@
"@id": "urn:solid-server:auth:password:RegistrationRoute",
"@type": "InteractionRoute",
"route": "^/register/?$",
"viewTemplate": "@css:templates/identity/email-password/register.html.ejs",
"responseTemplate": "@css:templates/identity/email-password/register-response.html.ejs",
"viewTemplates": {
"InteractionRoute:_viewTemplates_key": "text/html",
"InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/register.html.ejs"
},
"responseTemplates": {
"InteractionRoute:_responseTemplates_key": "text/html",
"InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/register-response.html.ejs"
},
"handler": {
"@type": "RegistrationHandler",
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
Expand Down
@@ -0,0 +1,27 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Uses the JSON data as parameters for a template.",
"@id": "urn:solid-server:default:DynamicJsonToTemplateConverter",
"@type": "DynamicJsonToTemplateConverter",
"extension": ".ejs",
"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"
}
]
}
}
]
}
2 changes: 2 additions & 0 deletions config/util/representation-conversion/default.json
Expand Up @@ -2,6 +2,7 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"import": [
"files-scs:config/util/representation-conversion/converters/content-type-replacer.json",
"files-scs:config/util/representation-conversion/converters/dynamic-json-template.json",
"files-scs:config/util/representation-conversion/converters/errors.json",
"files-scs:config/util/representation-conversion/converters/markdown.json",
"files-scs:config/util/representation-conversion/converters/quad-to-rdf.json",
Expand All @@ -15,6 +16,7 @@
"handlers": [
{ "@id": "urn:solid-server:default:DefaultUiConverter" },
{ "@id": "urn:solid-server:default:MarkdownToHtmlConverter" },
{ "@id": "urn:solid-server:default:DynamicJsonToTemplateConverter" },
{
"@type": "IfNeededConverter",
"comment": "Only continue converting if the requester cannot accept the available content type"
Expand Down
131 changes: 87 additions & 44 deletions src/identity/IdentityProviderHttpHandler.ts
@@ -1,55 +1,68 @@
import { DataFactory } from 'n3';
import urljoin from 'url-join';
import type { ErrorHandler } from '../ldp/http/ErrorHandler';
import type { RequestParser } from '../ldp/http/RequestParser';
import { OkResponseDescription } from '../ldp/http/response/OkResponseDescription';
import { RedirectResponseDescription } from '../ldp/http/response/RedirectResponseDescription';
import type { ResponseDescription } from '../ldp/http/response/ResponseDescription';
import type { ResponseWriter } from '../ldp/http/ResponseWriter';
import type { Operation } from '../ldp/operations/Operation';
import { BasicRepresentation } from '../ldp/representation/BasicRepresentation';
import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences';
import { getLoggerFor } from '../logging/LogUtil';
import type { HttpHandlerInput } from '../server/HttpHandler';
import { HttpHandler } from '../server/HttpHandler';
import type { HttpRequest } from '../server/HttpRequest';
import type { HttpResponse } from '../server/HttpResponse';
import type { TemplateHandler } from '../server/util/TemplateHandler';
import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter';
import { APPLICATION_JSON } from '../util/ContentTypes';
import { BadRequestHttpError } from '../util/errors/BadRequestHttpError';
import { assertError, createErrorMessage } from '../util/errors/ErrorUtil';
import { InternalServerError } from '../util/errors/InternalServerError';
import { trimTrailingSlashes } from '../util/PathUtil';
import { CONTENT_TYPE_TERM, SOLID_META } from '../util/Vocabularies';
import type { ProviderFactory } from './configuration/ProviderFactory';
import type { Interaction,
import type {
Interaction,
InteractionHandler,
InteractionHandlerResult } from './interaction/email-password/handler/InteractionHandler';
InteractionHandlerResult, InteractionResponseResult,
} from './interaction/email-password/handler/InteractionHandler';
import { IdpInteractionError } from './interaction/util/IdpInteractionError';
import type { InteractionCompleter } from './interaction/util/InteractionCompleter';
import namedNode = DataFactory.namedNode;

const API_VERSION = '0.1';

/**
* All the information that is required to handle a request to a custom IDP path.
*/
export class InteractionRoute {
public readonly route: RegExp;
public readonly handler: InteractionHandler;
public readonly viewTemplate: string;
public readonly viewTemplates: Record<string, string>;
public readonly prompt?: string;
public readonly responseTemplate?: string;
public readonly responseTemplates: Record<string, string>;

/**
* @param route - Regex to match this route.
* @param viewTemplate - Template to render on GET requests.
* @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.
* One entry should have a value of "default" here in case there are no prompt matches.
* @param responseTemplate - Template to render as a response to POST requests when required.
* @param responseTemplates - Templates to render as a response to POST requests when required.
* Keys are content-types, values paths to a template.
*/
public constructor(route: string,
viewTemplate: string,
viewTemplates: Record<string, string>,
handler: InteractionHandler,
prompt?: string,
responseTemplate?: string) {
responseTemplates: Record<string, string> = {}) {
this.route = new RegExp(route, 'u');
this.viewTemplate = viewTemplate;
this.viewTemplates = viewTemplates;
this.handler = handler;
this.prompt = prompt;
this.responseTemplate = responseTemplate;
this.responseTemplates = responseTemplates;
}
}

Expand All @@ -72,7 +85,7 @@ export class IdentityProviderHttpHandler extends HttpHandler {
private readonly requestParser: RequestParser;
private readonly providerFactory: ProviderFactory;
private readonly interactionRoutes: InteractionRoute[];
private readonly templateHandler: TemplateHandler;
private readonly converter: RepresentationConverter;
private readonly interactionCompleter: InteractionCompleter;
private readonly errorHandler: ErrorHandler;
private readonly responseWriter: ResponseWriter;
Expand All @@ -83,7 +96,7 @@ export class IdentityProviderHttpHandler extends HttpHandler {
* @param requestParser - Used for parsing requests.
* @param providerFactory - Used to generate the OIDC provider.
* @param interactionRoutes - All routes handling the custom IDP behaviour.
* @param templateHandler - Used for rendering responses.
* @param converter - Used for content negotiation..
* @param interactionCompleter - Used for POST requests that need to be handled by the OIDC library.
* @param errorHandler - Converts errors to responses.
* @param responseWriter - Renders error responses.
Expand All @@ -94,7 +107,7 @@ export class IdentityProviderHttpHandler extends HttpHandler {
requestParser: RequestParser,
providerFactory: ProviderFactory,
interactionRoutes: InteractionRoute[],
templateHandler: TemplateHandler,
converter: RepresentationConverter,
interactionCompleter: InteractionCompleter,
errorHandler: ErrorHandler,
responseWriter: ResponseWriter,
Expand All @@ -105,7 +118,7 @@ export class IdentityProviderHttpHandler extends HttpHandler {
this.requestParser = requestParser;
this.providerFactory = providerFactory;
this.interactionRoutes = interactionRoutes;
this.templateHandler = templateHandler;
this.converter = converter;
this.interactionCompleter = interactionCompleter;
this.errorHandler = errorHandler;
this.responseWriter = responseWriter;
Expand Down Expand Up @@ -142,30 +155,15 @@ export class IdentityProviderHttpHandler extends HttpHandler {
// If our own interaction handler does not support the input, it is either invalid or a request for the OIDC library
const route = await this.findRoute(operation, oidcInteraction);
if (!route) {
// Make sure the request stream still works in case the RequestParser read it
const provider = await this.providerFactory.getProvider();
this.logger.debug(`Sending request to oidc-provider: ${request.url}`);
return provider.callback(request, response);
}

const { result, templateFile } = await this.resolveRoute(operation, route, oidcInteraction);
if (result.type === 'complete') {
if (!oidcInteraction) {
// Once https://github.com/solid/community-server/pull/898 is merged
// we want to assign an error code here to have a more thorough explanation
throw new BadRequestHttpError(
'This action can only be executed as part of an authentication flow. It should not be used directly.',
);
}
// We need the original request object for the OIDC library
const location = await this.interactionCompleter.handleSafe({ ...result.details, request });
return await this.responseWriter.handleSafe({ response, result: new RedirectResponseDescription(location) });
}
if (result.type === 'response' && templateFile) {
return await this.handleTemplateResponse(response, templateFile, result.details, oidcInteraction);
}

throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`);
const { result, templateFiles } = await this.resolveRoute(operation, route, oidcInteraction);
const responseDescription =
await this.handleInteractionResult(operation, request, result, templateFiles, oidcInteraction);
await this.responseWriter.handleSafe({ response, result: responseDescription });
}

/**
Expand All @@ -190,41 +188,86 @@ export class IdentityProviderHttpHandler extends HttpHandler {
* Handles the behaviour of an InteractionRoute.
* Will error if the route does not support the given request.
*
* GET requests go to the templateHandler, POST requests to the specific InteractionHandler of the route.
* GET requests return a default response result,
* POST requests to the specific InteractionHandler of the route.
*/
private async resolveRoute(operation: Operation, route: InteractionRoute, oidcInteraction?: Interaction):
Promise<{ result: InteractionHandlerResult; templateFile?: string }> {
Promise<{ result: InteractionHandlerResult; templateFiles: Record<string, string> }> {
if (operation.method === 'GET') {
// .ejs templates errors on undefined variables
return {
result: { type: 'response', details: { errorMessage: '', prefilled: {}}},
templateFile: route.viewTemplate,
templateFiles: route.viewTemplates,
};
}

if (operation.method === 'POST') {
try {
const result = await route.handler.handleSafe({ operation, oidcInteraction });
return { result, templateFile: route.responseTemplate };
return { result, templateFiles: route.responseTemplates };
} catch (error: unknown) {
// Render error in the view
const prefilled = IdpInteractionError.isInstance(error) ? error.prefilled : {};
const errorMessage = createErrorMessage(error);
return {
result: { type: 'response', details: { errorMessage, prefilled }},
templateFile: route.viewTemplate,
templateFiles: route.viewTemplates,
};
}
}

throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`);
}

private async handleTemplateResponse(response: HttpResponse, templateFile: string, data?: NodeJS.Dict<any>,
oidcInteraction?: Interaction): Promise<void> {
const contents = data ?? {};
contents.authenticating = Boolean(oidcInteraction);
await this.templateHandler.handleSafe({ response, templateFile, contents });
/**
* Creates a ResponseDescription based on the InteractionHandlerResult.
* This will either be a redirect if type is "complete" or a data stream if the type is "response".
*/
private async handleInteractionResult(operation: Operation, request: HttpRequest, result: InteractionHandlerResult,
templateFiles: Record<string, string>, oidcInteraction?: Interaction): Promise<ResponseDescription> {
let responseDescription: ResponseDescription | undefined;

if (result.type === 'complete') {
if (!oidcInteraction) {
// Once https://github.com/solid/community-server/pull/898 is merged
// we want to assign an error code here to have a more thorough explanation
throw new BadRequestHttpError(
'This action can only be executed as part of an authentication flow. It should not be used directly.',
);
}
// Create a redirect URL with the OIDC library
const location = await this.interactionCompleter.handleSafe({ ...result.details, request });
responseDescription = new RedirectResponseDescription(location);
} else {
// Convert the response object to a data stream
responseDescription = await this.handleResponseResult(result, templateFiles, operation, oidcInteraction);
}

return responseDescription;
}

/**
* Converts an InteractionResponseResult to a ResponseDescription by first converting to a Representation
* and applying necessary conversions.
*/
private async handleResponseResult(result: InteractionResponseResult, templateFiles: Record<string, string>,
operation: Operation, oidcInteraction?: Interaction): Promise<ResponseDescription> {
// Convert the object to a valid JSON representation
const json = { ...result.details, authenticating: Boolean(oidcInteraction), apiVersion: API_VERSION };
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)) {
const templateNode = namedNode(templateFile);
representation.metadata.add(SOLID_META.terms.template, templateNode);
representation.metadata.addQuad(templateNode, CONTENT_TYPE_TERM, 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 OkResponseDescription(converted.metadata, converted.data);
}

/**
Expand Down

0 comments on commit 5de056e

Please sign in to comment.