From 60ebf5454ac250140c28e1debd1682e7c615669f Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Fri, 9 Jul 2021 10:41:52 +0200 Subject: [PATCH] feat: Let InitialInteractionHandler redirect requests --- .../handler/interaction/handlers/initial.json | 15 ++---- .../handler/interaction/handlers/session.json | 18 ++++++-- .../util/InitialInteractionHandler.ts | 44 +++++++++--------- test/integration/IdentityTestState.ts | 7 ++- .../util/InitialInteractionHandler.test.ts | 46 ++++++++----------- 5 files changed, 65 insertions(+), 65 deletions(-) diff --git a/config/identity/handler/interaction/handlers/initial.json b/config/identity/handler/interaction/handlers/initial.json index c4639e91a..64ba556b3 100644 --- a/config/identity/handler/interaction/handlers/initial.json +++ b/config/identity/handler/interaction/handlers/initial.json @@ -9,19 +9,14 @@ "allowedPathNames": [ "^/idp/?$" ], "handler": { "@type": "InitialInteractionHandler", - "renderHandlerMap": [ + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "redirectMap": [ { - "InitialInteractionHandler:_renderHandlerMap_key": "consent", - "InitialInteractionHandler:_renderHandlerMap_value": { - "@type": "TemplateHandler", - "templateEngine": { - "@type": "EjsTemplateEngine", - "template": "$PACKAGE_ROOT/templates/identity/email-password/confirm.html.ejs" - } - } + "InitialInteractionHandler:_redirectMap_key": "consent", + "InitialInteractionHandler:_redirectMap_value": "/idp/confirm" } ], - "renderHandlerMap_default": { "@id": "urn:solid-server:auth:password:LoginRenderHandler" } + "redirectMap_default": "/idp/login" } } ] diff --git a/config/identity/handler/interaction/handlers/session.json b/config/identity/handler/interaction/handlers/session.json index a1bc0b942..3efcc2584 100644 --- a/config/identity/handler/interaction/handlers/session.json +++ b/config/identity/handler/interaction/handlers/session.json @@ -4,12 +4,22 @@ { "comment": "Handles confirm requests", "@id": "urn:solid-server:auth:password:SessionInteractionHandler", - "@type": "RouterHandler", - "allowedMethods": [ "POST" ], - "allowedPathNames": [ "^/idp/confirm/?$" ], - "handler": { + "@type": "IdpRouteController", + "pathName": "^/idp/confirm/?$", + "postHandler": { "@type": "SessionHttpHandler", "interactionCompleter": { "@id": "urn:solid-server:auth:password:InteractionCompleter" } + }, + "renderHandler": { "@id": "urn:solid-server:auth:password:ConfirmRenderHandler" } + }, + + { + "comment": "Renders the confirmation page", + "@id": "urn:solid-server:auth:password:ConfirmRenderHandler", + "@type": "TemplateHandler", + "templateEngine": { + "@type": "EjsTemplateEngine", + "template": "$PACKAGE_ROOT/templates/identity/email-password/confirm.html.ejs" } } ] diff --git a/src/identity/interaction/util/InitialInteractionHandler.ts b/src/identity/interaction/util/InitialInteractionHandler.ts index 663a72749..2ca8dcac6 100644 --- a/src/identity/interaction/util/InitialInteractionHandler.ts +++ b/src/identity/interaction/util/InitialInteractionHandler.ts @@ -1,47 +1,45 @@ +import urljoin from 'url-join'; import { getLoggerFor } from '../../../logging/LogUtil'; import type { InteractionHttpHandlerInput } from '../InteractionHttpHandler'; import { InteractionHttpHandler } from '../InteractionHttpHandler'; -import type { IdpRenderHandler } from './IdpRenderHandler'; -export interface RenderHandlerMap { - [key: string]: IdpRenderHandler; - default: IdpRenderHandler; +export interface RedirectMap { + [key: string]: string; + default: string; } /** - * An {@link InteractionHttpHandler} that redirects requests - * to a specific {@link IdpRenderHandler} based on their prompt. + * An {@link InteractionHttpHandler} that redirects requests based on their prompt. * A list of possible prompts can be found at https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest * In case there is no prompt or there is no match in the input map, - * the `default` render handler will be used. + * the `default` redirect will be used. * - * Specifically, the prompt determines how the server should handle re-authentication and consent. - * - * Since this class specifically redirects to render handlers, - * it is advised to wrap it in a {@link RouterHandler} that only allows GET requests. + * Specifically, this is used to redirect the client to the correct way to login, + * such as a login page, or a confirmation page if a login procedure already succeeded previously. */ export class InitialInteractionHandler extends InteractionHttpHandler { protected readonly logger = getLoggerFor(this); - private readonly renderHandlerMap: RenderHandlerMap; + private readonly baseUrl: string; + private readonly redirectMap: RedirectMap; - public constructor(renderHandlerMap: RenderHandlerMap) { + public constructor(baseUrl: string, redirectMap: RedirectMap) { super(); - this.renderHandlerMap = renderHandlerMap; + this.baseUrl = baseUrl; + this.redirectMap = redirectMap; } public async handle({ request, response, provider }: InteractionHttpHandlerInput): Promise { + // Find the matching redirect in the map or take the default const interactionDetails = await provider.interactionDetails(request, response); - const name = interactionDetails.prompt.name in this.renderHandlerMap ? interactionDetails.prompt.name : 'default'; + const name = interactionDetails.prompt.name in this.redirectMap ? interactionDetails.prompt.name : 'default'; - this.logger.debug(`Calling ${name} render handler.`); + // Create a valid redirect URL + const location = urljoin(this.baseUrl, this.redirectMap[name]); + this.logger.debug(`Redirecting ${name} prompt to ${location}.`); - await this.renderHandlerMap[name].handleSafe({ - response, - contents: { - errorMessage: '', - prefilled: {}, - }, - }); + // Redirect to the result + response.writeHead(302, { location }); + response.end(); } } diff --git a/test/integration/IdentityTestState.ts b/test/integration/IdentityTestState.ts index 167c849a1..5ff326830 100644 --- a/test/integration/IdentityTestState.ts +++ b/test/integration/IdentityTestState.ts @@ -87,7 +87,12 @@ export class IdentityTestState { expect(nextUrl.startsWith(this.oidcIssuer)).toBeTruthy(); // Need to catch the redirect so we can copy the cookies - const res = await this.fetchIdp(nextUrl); + let res = await this.fetchIdp(nextUrl); + expect(res.status).toBe(302); + nextUrl = res.headers.get('location')!; + + // Redirect from main page to specific page (login or confirmation) + res = await this.fetchIdp(nextUrl); expect(res.status).toBe(302); nextUrl = res.headers.get('location')!; diff --git a/test/unit/identity/interaction/util/InitialInteractionHandler.test.ts b/test/unit/identity/interaction/util/InitialInteractionHandler.test.ts index 45889df20..eaacf4e4c 100644 --- a/test/unit/identity/interaction/util/InitialInteractionHandler.test.ts +++ b/test/unit/identity/interaction/util/InitialInteractionHandler.test.ts @@ -1,45 +1,43 @@ +import type { MockResponse } from 'node-mocks-http'; +import { createResponse } from 'node-mocks-http'; import type { Provider } from 'oidc-provider'; -import type { RenderHandlerMap } from '../../../../../src/identity/interaction/util/InitialInteractionHandler'; +import type { RedirectMap } from '../../../../../src/identity/interaction/util/InitialInteractionHandler'; import { InitialInteractionHandler } from '../../../../../src/identity/interaction/util/InitialInteractionHandler'; import type { HttpRequest } from '../../../../../src/server/HttpRequest'; -import type { HttpResponse } from '../../../../../src/server/HttpResponse'; describe('An InitialInteractionHandler', (): void => { + const baseUrl = 'http://test.com/'; const request: HttpRequest = {} as any; - const response: HttpResponse = {} as any; - let provider: Provider; + let response: MockResponse; + let provider: jest.Mocked; // `Interaction` type is not exposed let details: any; - let map: RenderHandlerMap; + let map: RedirectMap; let handler: InitialInteractionHandler; beforeEach(async(): Promise => { + response = createResponse(); + map = { - default: { handleSafe: jest.fn() }, - test: { handleSafe: jest.fn() }, - } as any; + default: '/idp/login', + test: '/idp/test', + }; details = { prompt: { name: 'test' }}; provider = { interactionDetails: jest.fn().mockResolvedValue(details), } as any; - handler = new InitialInteractionHandler(map); + handler = new InitialInteractionHandler(baseUrl, map); }); it('uses the named handler if it is found.', async(): Promise => { await expect(handler.handle({ request, response, provider })).resolves.toBeUndefined(); expect(provider.interactionDetails).toHaveBeenCalledTimes(1); expect(provider.interactionDetails).toHaveBeenLastCalledWith(request, response); - expect(map.default.handleSafe).toHaveBeenCalledTimes(0); - expect(map.test.handleSafe).toHaveBeenCalledTimes(1); - expect(map.test.handleSafe).toHaveBeenLastCalledWith({ - response, - contents: { - errorMessage: '', - prefilled: {}, - }, - }); + expect(response._isEndCalled()).toBe(true); + expect(response.getHeader('location')).toBe('http://test.com/idp/test'); + expect(response.statusCode).toBe(302); }); it('uses the default handler if there is no match.', async(): Promise => { @@ -47,14 +45,8 @@ describe('An InitialInteractionHandler', (): void => { await expect(handler.handle({ request, response, provider })).resolves.toBeUndefined(); expect(provider.interactionDetails).toHaveBeenCalledTimes(1); expect(provider.interactionDetails).toHaveBeenLastCalledWith(request, response); - expect(map.default.handleSafe).toHaveBeenCalledTimes(1); - expect(map.test.handleSafe).toHaveBeenCalledTimes(0); - expect(map.default.handleSafe).toHaveBeenLastCalledWith({ - response, - contents: { - errorMessage: '', - prefilled: {}, - }, - }); + expect(response._isEndCalled()).toBe(true); + expect(response.getHeader('location')).toBe('http://test.com/idp/login'); + expect(response.statusCode).toBe(302); }); });