Skip to content

Commit

Permalink
feat: Move redirect support from IDP handler to specific handlers
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Dec 9, 2021
1 parent 7163a03 commit 4241c53
Show file tree
Hide file tree
Showing 24 changed files with 350 additions and 246 deletions.
5 changes: 0 additions & 5 deletions config/identity/handler/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,6 @@
"args_idpPath": "/idp",
"args_providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" },
"args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"args_interactionCompleter": {
"comment": "Responsible for finishing OIDC interactions.",
"@type": "InteractionCompleter",
"providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }
},
"args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" }
}
]
Expand Down
3 changes: 2 additions & 1 deletion config/identity/handler/interaction/routes/login.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
},
"handler": {
"@type": "LoginHandler",
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
"interactionCompleter": { "@type": "BaseInteractionCompleter" }
}
}
]
Expand Down
5 changes: 4 additions & 1 deletion config/identity/handler/interaction/routes/session.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
"BasicInteractionRoute:_viewTemplates_key": "text/html",
"BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/confirm.html.ejs"
},
"handler": { "@type": "SessionHttpHandler" }
"handler": {
"@type": "SessionHttpHandler",
"interactionCompleter": { "@type": "BaseInteractionCompleter" }
}
}
]
}
32 changes: 5 additions & 27 deletions src/identity/IdentityProviderHttpHandler.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,20 @@
import type { Operation } from '../http/Operation';
import type { ErrorHandler } from '../http/output/error/ErrorHandler';
import { RedirectResponseDescription } from '../http/output/response/RedirectResponseDescription';
import { ResponseDescription } from '../http/output/response/ResponseDescription';
import { BasicRepresentation } from '../http/representation/BasicRepresentation';
import { getLoggerFor } from '../logging/LogUtil';
import type { HttpRequest } from '../server/HttpRequest';
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 { BadRequestHttpError } from '../util/errors/BadRequestHttpError';
import { FoundHttpError } from '../util/errors/FoundHttpError';
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/email-password/handler/InteractionHandler';
import type { Interaction } from './interaction/InteractionHandler';
import type { InteractionRoute, TemplatedInteractionResult } from './interaction/routing/InteractionRoute';
import type { InteractionCompleter } from './interaction/util/InteractionCompleter';

// Registration is not standardized within Solid yet, so we use a custom versioned API for now
const API_VERSION = '0.2';

export interface IdentityProviderHttpHandlerArgs {
Expand All @@ -44,10 +38,6 @@ export interface IdentityProviderHttpHandlerArgs {
* Used for content negotiation.
*/
converter: RepresentationConverter;
/**
* Used for POST requests that need to be handled by the OIDC library.
*/
interactionCompleter: InteractionCompleter;
/**
* Used for converting output errors.
*/
Expand All @@ -73,7 +63,6 @@ export class IdentityProviderHttpHandler extends OperationHttpHandler {
private readonly providerFactory: ProviderFactory;
private readonly interactionRoutes: InteractionRoute[];
private readonly converter: RepresentationConverter;
private readonly interactionCompleter: InteractionCompleter;
private readonly errorHandler: ErrorHandler;

private readonly controls: Record<string, string>;
Expand All @@ -85,7 +74,6 @@ export class IdentityProviderHttpHandler extends OperationHttpHandler {
this.providerFactory = args.providerFactory;
this.interactionRoutes = args.interactionRoutes;
this.converter = args.converter;
this.interactionCompleter = args.interactionCompleter;
this.errorHandler = args.errorHandler;

this.controls = Object.assign(
Expand Down Expand Up @@ -131,7 +119,7 @@ export class IdentityProviderHttpHandler extends OperationHttpHandler {
// Reset the body so it can be reused when needed for output
operation.body = clone;

return this.handleInteractionResult(operation, request, result, oidcInteraction);
return this.handleInteractionResult(operation, result, oidcInteraction);
}

/**
Expand All @@ -155,21 +143,11 @@ export class IdentityProviderHttpHandler extends OperationHttpHandler {
* 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: TemplatedInteractionResult, oidcInteraction?: Interaction): Promise<ResponseDescription> {
private async handleInteractionResult(operation: Operation, result: TemplatedInteractionResult,
oidcInteraction?: Interaction): Promise<ResponseDescription> {
let responseDescription: ResponseDescription | undefined;

if (result.type === 'complete') {
if (!oidcInteraction) {
throw new BadRequestHttpError(
'This action can only be performed as part of an OIDC authentication flow.',
{ errorCode: 'E0002' },
);
}
// Create a redirect URL with the OIDC library
const location = await this.interactionCompleter.handleSafe({ ...result.details, request });
responseDescription = new RedirectResponseDescription(new FoundHttpError(location));
} else if (result.type === 'error') {
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 });
Expand Down
47 changes: 47 additions & 0 deletions src/identity/interaction/CompletingInteractionHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { FoundHttpError } from '../../util/errors/FoundHttpError';
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}.
* 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.
*
* Calls the InteractionCompleter with the results returned by the helper function
* and throw a corresponding {@link FoundHttpError}.
*/
export abstract class CompletingInteractionHandler extends InteractionHandler {
protected readonly interactionCompleter: InteractionCompleter;

protected constructor(interactionCompleter: InteractionCompleter) {
super();
this.interactionCompleter = interactionCompleter;
}

public async canHandle(input: InteractionHandlerInput): Promise<void> {
await super.canHandle(input);
if (!input.oidcInteraction) {
throw new BadRequestHttpError(
'This action can only be performed as part of an OIDC authentication flow.',
{ errorCode: 'E0002' },
);
}
}

public async handle(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);
throw new FoundHttpError(location);
}

/**
* Generates the parameters necessary to call an InteractionCompleter.
* @param input - The original input parameters to the `handle` function.
*/
protected abstract getCompletionParameters(input: Required<InteractionHandlerInput>):
Promise<InteractionCompleterInput>;
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import type { KoaContextWithOIDC } from 'oidc-provider';
import type { Operation } from '../../../../http/Operation';
import { APPLICATION_JSON } from '../../../../util/ContentTypes';
import { NotImplementedHttpError } from '../../../../util/errors/NotImplementedHttpError';
import { AsyncHandler } from '../../../../util/handlers/AsyncHandler';
import type { InteractionCompleterParams } from '../../util/InteractionCompleter';
import type { Operation } from '../../http/Operation';
import { APPLICATION_JSON } from '../../util/ContentTypes';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { AsyncHandler } from '../../util/handlers/AsyncHandler';

// OIDC library does not directly export the Interaction type
export type Interaction = KoaContextWithOIDC['oidc']['entities']['Interaction'];
export type Interaction = NonNullable<KoaContextWithOIDC['oidc']['entities']['Interaction']>;

export interface InteractionHandlerInput {
/**
Expand All @@ -20,18 +19,13 @@ export interface InteractionHandlerInput {
oidcInteraction?: Interaction;
}

export type InteractionHandlerResult = InteractionResponseResult | InteractionCompleteResult | InteractionErrorResult;
export type InteractionHandlerResult = InteractionResponseResult | InteractionErrorResult;

export interface InteractionResponseResult<T = NodeJS.Dict<any>> {
type: 'response';
details?: T;
}

export interface InteractionCompleteResult {
type: 'complete';
details: InteractionCompleterParams;
}

export interface InteractionErrorResult {
type: 'error';
error: Error;
Expand Down
22 changes: 13 additions & 9 deletions src/identity/interaction/SessionHttpHandler.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { readJsonStream } from '../../util/StreamUtil';
import { InteractionHandler } from './email-password/handler/InteractionHandler';
import type { InteractionCompleteResult, InteractionHandlerInput } from './email-password/handler/InteractionHandler';
import { CompletingInteractionHandler } from './CompletingInteractionHandler';
import type { InteractionHandlerInput } from './InteractionHandler';
import type { InteractionCompleter, InteractionCompleterInput } from './util/InteractionCompleter';

/**
* Simple InteractionHttpHandler that sends the session accountId to the InteractionCompleter as webId.
* This is relevant when a client already logged in this session and tries logging in again.
*/
export class SessionHttpHandler extends InteractionHandler {
public async handle({ operation, oidcInteraction }: InteractionHandlerInput): Promise<InteractionCompleteResult> {
if (!oidcInteraction?.session) {
export class SessionHttpHandler extends CompletingInteractionHandler {
public constructor(interactionCompleter: InteractionCompleter) {
super(interactionCompleter);
}

protected async getCompletionParameters({ operation, oidcInteraction }: Required<InteractionHandlerInput>):
Promise<InteractionCompleterInput> {
if (!oidcInteraction.session) {
throw new NotImplementedHttpError('Only interactions with a valid session are supported.');
}

const { remember } = await readJsonStream(operation.body.data);
return {
type: 'complete',
details: { webId: oidcInteraction.session.accountId, shouldRemember: Boolean(remember) },
};
return { oidcInteraction, webId: oidcInteraction.session.accountId, shouldRemember: Boolean(remember) };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { getLoggerFor } from '../../../../logging/LogUtil';
import { ensureTrailingSlash, joinUrl } from '../../../../util/PathUtil';
import { readJsonStream } from '../../../../util/StreamUtil';
import type { TemplateEngine } from '../../../../util/templates/TemplateEngine';
import { InteractionHandler } from '../../InteractionHandler';
import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler';
import type { EmailSender } from '../../util/EmailSender';
import type { AccountStore } from '../storage/AccountStore';
import { InteractionHandler } from './InteractionHandler';
import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler';

export interface ForgotPasswordHandlerArgs {
accountStore: AccountStore;
Expand Down
22 changes: 12 additions & 10 deletions src/identity/interaction/email-password/handler/LoginHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,28 @@ import type { Operation } from '../../../../http/Operation';
import { getLoggerFor } from '../../../../logging/LogUtil';
import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError';
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';
import { InteractionHandler } from './InteractionHandler';
import type { InteractionCompleteResult, InteractionHandlerInput } from './InteractionHandler';

/**
* Handles the submission of the Login Form and logs the user in.
* Will throw a RedirectHttpError on success.
*/
export class LoginHandler extends InteractionHandler {
export class LoginHandler extends CompletingInteractionHandler {
protected readonly logger = getLoggerFor(this);

private readonly accountStore: AccountStore;

public constructor(accountStore: AccountStore) {
super();
public constructor(accountStore: AccountStore, interactionCompleter: InteractionCompleter) {
super(interactionCompleter);
this.accountStore = accountStore;
}

public async handle({ operation }: InteractionHandlerInput): Promise<InteractionCompleteResult> {
protected async getCompletionParameters({ operation, oidcInteraction }: Required<InteractionHandlerInput>):
Promise<InteractionCompleterInput> {
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);
Expand All @@ -30,10 +34,8 @@ export class LoginHandler extends InteractionHandler {
throw new BadRequestHttpError('This server is not an identity provider for this account.');
}
this.logger.debug(`Logging in user ${email}`);
return {
type: 'complete',
details: { webId, shouldRemember: remember },
};

return { oidcInteraction, webId, shouldRemember: remember };
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { getLoggerFor } from '../../../../logging/LogUtil';
import { readJsonStream } from '../../../../util/StreamUtil';
import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler';
import { InteractionHandler } from '../../InteractionHandler';
import type { RegistrationManager, RegistrationResponse } from '../util/RegistrationManager';
import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler';
import { InteractionHandler } from './InteractionHandler';

/**
* Supports registration based on the `RegistrationManager` behaviour.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import assert from 'assert';
import { getLoggerFor } from '../../../../logging/LogUtil';
import { readJsonStream } from '../../../../util/StreamUtil';
import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler';
import { InteractionHandler } from '../../InteractionHandler';
import { assertPassword } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore';
import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler';
import { InteractionHandler } from './InteractionHandler';

/**
* Handles the submission of the ResetPassword form:
Expand Down
8 changes: 7 additions & 1 deletion src/identity/interaction/routing/BasicInteractionRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ 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 '../email-password/handler/InteractionHandler';
} from '../InteractionHandler';
import type { InteractionRoute, TemplatedInteractionResult } from './InteractionRoute';

/**
Expand Down Expand Up @@ -84,6 +85,11 @@ export class BasicInteractionRoute implements InteractionRoute {
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 };
Expand Down
2 changes: 1 addition & 1 deletion src/identity/interaction/routing/InteractionRoute.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Operation } from '../../../http/Operation';
import type { Interaction, InteractionHandlerResult } from '../email-password/handler/InteractionHandler';
import type { Interaction, InteractionHandlerResult } from '../InteractionHandler';

export type TemplatedInteractionResult<T extends InteractionHandlerResult = InteractionHandlerResult> = T & {
templateFiles: Record<string, string>;
Expand Down
37 changes: 37 additions & 0 deletions src/identity/interaction/util/BaseInteractionCompleter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { InteractionResults } from 'oidc-provider';
import type { InteractionCompleterInput } from './InteractionCompleter';
import { InteractionCompleter } from './InteractionCompleter';

/**
* Creates a simple InteractionResults object based on the input parameters and injects it in the Interaction.
*/
export class BaseInteractionCompleter extends InteractionCompleter {
public async handle(input: InteractionCompleterInput): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const result: InteractionResults = {
login: {
account: input.webId,
// Indicates if a persistent cookie should be used instead of a session cookie.
remember: input.shouldRemember,
ts: now,
},
consent: {
// When OIDC clients want a refresh token, they need to request the 'offline_access' scope.
// This indicates that this scope is not granted to the client in case they do not want to be remembered.
rejectedScopes: input.shouldRemember ? [] : [ 'offline_access' ],
},
};

// Generates the URL a client needs to be redirected to
// after a successful interaction completion (such as logging in).
// Identical behaviour to calling `provider.interactionResult`.
// We use the code below instead of calling that function
// since that function also uses Request/Response objects to generate the Interaction object,
// which we already have here.
const { oidcInteraction } = input;
oidcInteraction.result = { ...oidcInteraction.lastSubmission, ...result };
await oidcInteraction.save(oidcInteraction.exp - now);

return oidcInteraction.returnTo;
}
}
Loading

0 comments on commit 4241c53

Please sign in to comment.