feat: Add controls to IDP response JSON

Controls are now used in templates to prevent IDP URL hardcoding
This commit is contained in:
Joachim Van Herwegen 2021-08-25 14:49:57 +02:00
parent d68854a474
commit 32a182dde8
13 changed files with 80 additions and 58 deletions

View File

@ -14,6 +14,10 @@
"InteractionRoute:_responseTemplates_key": "text/html", "InteractionRoute:_responseTemplates_key": "text/html",
"InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/forgot-password-response.html.ejs" "InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/forgot-password-response.html.ejs"
}, },
"controls": {
"InteractionRoute:_controls_key": "forgotPassword",
"InteractionRoute:_controls_value": "/forgotpassword"
},
"handler": { "handler": {
"@type": "ForgotPasswordHandler", "@type": "ForgotPasswordHandler",
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },

View File

@ -11,6 +11,10 @@
"InteractionRoute:_viewTemplates_key": "text/html", "InteractionRoute:_viewTemplates_key": "text/html",
"InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/login.html.ejs" "InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/login.html.ejs"
}, },
"controls": {
"InteractionRoute:_controls_key": "login",
"InteractionRoute:_controls_value": "/login"
},
"handler": { "handler": {
"@type": "LoginHandler", "@type": "LoginHandler",
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" } "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }

View File

@ -14,6 +14,10 @@
"InteractionRoute:_responseTemplates_key": "text/html", "InteractionRoute:_responseTemplates_key": "text/html",
"InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/register-response.html.ejs" "InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/register-response.html.ejs"
}, },
"controls": {
"InteractionRoute:_controls_key": "register",
"InteractionRoute:_controls_value": "/register"
},
"handler": { "handler": {
"@type": "RegistrationHandler", "@type": "RegistrationHandler",
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },

View File

@ -27,7 +27,8 @@ import type {
import { IdpInteractionError } from './interaction/util/IdpInteractionError'; import { IdpInteractionError } from './interaction/util/IdpInteractionError';
import type { InteractionCompleter } from './interaction/util/InteractionCompleter'; import type { InteractionCompleter } from './interaction/util/InteractionCompleter';
const API_VERSION = '0.1'; // Registration is not standardized within Solid yet, so we use a custom versioned API for now
const API_VERSION = '0.2';
/** /**
* All the information that is required to handle a request to a custom IDP path. * All the information that is required to handle a request to a custom IDP path.
@ -38,6 +39,7 @@ export class InteractionRoute {
public readonly viewTemplates: Record<string, string>; public readonly viewTemplates: Record<string, string>;
public readonly prompt?: string; public readonly prompt?: string;
public readonly responseTemplates: Record<string, string>; public readonly responseTemplates: Record<string, string>;
public readonly controls: Record<string, string>;
/** /**
* @param route - Regex to match this route. * @param route - Regex to match this route.
@ -47,17 +49,21 @@ export class InteractionRoute {
* @param prompt - In case of requests to the IDP entry point, the session prompt will be compared to this. * @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. * @param responseTemplates - Templates to render as a response to POST requests when required.
* Keys are content-types, values paths to a template. * 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, public constructor(route: string,
viewTemplates: Record<string, string>, viewTemplates: Record<string, string>,
handler: InteractionHandler, handler: InteractionHandler,
prompt?: string, prompt?: string,
responseTemplates: Record<string, string> = {}) { responseTemplates: Record<string, string> = {},
controls: Record<string, string> = {}) {
this.route = new RegExp(route, 'u'); this.route = new RegExp(route, 'u');
this.viewTemplates = viewTemplates; this.viewTemplates = viewTemplates;
this.handler = handler; this.handler = handler;
this.prompt = prompt; this.prompt = prompt;
this.responseTemplates = responseTemplates; this.responseTemplates = responseTemplates;
this.controls = controls;
} }
} }
@ -113,6 +119,8 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
private readonly converter: RepresentationConverter; private readonly converter: RepresentationConverter;
private readonly interactionCompleter: InteractionCompleter; private readonly interactionCompleter: InteractionCompleter;
private readonly controls: Record<string, string>;
public constructor(args: IdentityProviderHttpHandlerArgs) { public constructor(args: IdentityProviderHttpHandlerArgs) {
// It is important that the RequestParser does not read out the Request body stream. // It is important that the RequestParser does not read out the Request body stream.
// Otherwise we can't pass it anymore to the OIDC library when needed. // Otherwise we can't pass it anymore to the OIDC library when needed.
@ -123,6 +131,11 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
this.interactionRoutes = args.interactionRoutes; this.interactionRoutes = args.interactionRoutes;
this.converter = args.converter; this.converter = args.converter;
this.interactionCompleter = args.interactionCompleter; this.interactionCompleter = args.interactionCompleter;
this.controls = Object.assign(
{},
...this.interactionRoutes.map((route): Record<string, string> => this.getRouteControls(route)),
);
} }
/** /**
@ -258,7 +271,12 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
private async handleResponseResult(result: InteractionResponseResult, templateFiles: Record<string, string>, private async handleResponseResult(result: InteractionResponseResult, templateFiles: Record<string, string>,
operation: Operation, oidcInteraction?: Interaction): Promise<ResponseDescription> { operation: Operation, oidcInteraction?: Interaction): Promise<ResponseDescription> {
// Convert the object to a valid JSON representation // Convert the object to a valid JSON representation
const json = { ...result.details, authenticating: Boolean(oidcInteraction), apiVersion: API_VERSION }; const json = {
apiVersion: API_VERSION,
...result.details,
authenticating: Boolean(oidcInteraction),
controls: this.controls,
};
const representation = new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON); const representation = new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON);
// Template metadata is required for conversion // Template metadata is required for conversion
@ -272,4 +290,13 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
return new OkResponseDescription(converted.metadata, converted.data); return new OkResponseDescription(converted.metadata, converted.data);
} }
/**
* Converts the controls object of a route to one with full URLs.
*/
private getRouteControls(route: InteractionRoute): Record<string, string> {
return Object.fromEntries(
Object.entries(route.controls).map(([ name, path ]): [ string, string ] => [ name, joinUrl(this.baseUrl, path) ]),
);
}
} }

View File

@ -1,6 +1,6 @@
<h1>Authorize</h1> <h1>Authorize</h1>
<p>You are authorizing an application to access your Pod.</p> <p>You are authorizing an application to access your Pod.</p>
<form action="/idp/confirm" method="post"> <form method="post">
<% if (errorMessage) { %> <% if (errorMessage) { %>
<p class="error"><%= errorMessage %></p> <p class="error"><%= errorMessage %></p>
<% } %> <% } %>

View File

@ -1,11 +1,11 @@
<h1>Email sent</h1> <h1>Email sent</h1>
<form action="/idp/forgotpassword" method="post"> <form method="post">
<p>If your account exists, an email has been sent with a link to reset your password.</p> <p>If your account exists, an email has been sent with a link to reset your password.</p>
<p>If you do not receive your email in a couple of minutes, check your spam folder or click the link below to send another email.</p> <p>If you do not receive your email in a couple of minutes, check your spam folder or click the link below to send another email.</p>
<input type="hidden" name="email" value="<%= email %>" /> <input type="hidden" name="email" value="<%= email %>" />
<p class="actions"><a href="/idp/login">Back to Log In</a></p> <p class="actions"><a href="<%= controls.login %>">Back to Log In</a></p>
<p class="actions"> <p class="actions">
<button type="submit" name="submit" class="link">Send Another Email</button> <button type="submit" name="submit" class="link">Send Another Email</button>

View File

@ -1,5 +1,5 @@
<h1>Forgot password</h1> <h1>Forgot password</h1>
<form action="/idp/forgotpassword" method="post"> <form method="post">
<% if (errorMessage) { %> <% if (errorMessage) { %>
<p class="error"><%= errorMessage %></p> <p class="error"><%= errorMessage %></p>
<% } %> <% } %>
@ -15,5 +15,5 @@
<p class="actions"><button type="submit" name="submit">Send recovery email</button></p> <p class="actions"><button type="submit" name="submit">Send recovery email</button></p>
<p class="actions"><a href="/idp/login" class="link">Log in</a></p> <p class="actions"><a href="<%= controls.login %>" class="link">Log in</a></p>
</form> </form>

View File

@ -1,5 +1,5 @@
<h1>Log in</h1> <h1>Log in</h1>
<form action="/idp/login" method="post"> <form method="post">
<% if (errorMessage) { %> <% if (errorMessage) { %>
<p class="error"><%= errorMessage %></p> <p class="error"><%= errorMessage %></p>
<% } %> <% } %>
@ -24,7 +24,7 @@
<p class="actions"><button type="submit" name="submit">Log in</button></p> <p class="actions"><button type="submit" name="submit">Log in</button></p>
<ul class="actions"> <ul class="actions">
<li><a href="/idp/register" class="link">Sign up</a></li> <li><a href="<%= controls.register %>" class="link">Sign up</a></li>
<li><a href="/idp/forgotpassword" class="link">Forgot password</a></li> <li><a href="<%= controls.forgotPassword %>" class="link">Forgot password</a></li>
</ul> </ul>
</form> </form>

View File

@ -27,7 +27,7 @@
<p> <p>
Via your email address <em><%= email %></em>, Via your email address <em><%= email %></em>,
<% if (authenticating) { %> <% if (authenticating) { %>
you can now <a href="./login">log in</a> you can now <a href="<%= controls.login %>">log in</a>
<% } else { %> <% } else { %>
this server lets you log in to Solid apps this server lets you log in to Solid apps
<% } %> <% } %>

View File

@ -1,5 +1,5 @@
<h1>Sign up</h1> <h1>Sign up</h1>
<form action="/idp/register" method="post" id="mainForm"> <form method="post" id="mainForm">
<% const isBlankForm = !('email' in prefilled); %> <% const isBlankForm = !('email' in prefilled); %>
<% if (errorMessage) { %> <% if (errorMessage) { %>

View File

@ -145,9 +145,8 @@ describe('A Solid server with IDP', (): void => {
it('initializes the session and logs in.', async(): Promise<void> => { it('initializes the session and logs in.', async(): Promise<void> => {
const url = await state.startSession(); const url = await state.startSession();
const { login } = await state.parseLoginPage(url); await state.parseLoginPage(url);
expect(typeof login).toBe('string'); await state.login(url, email, password);
await state.login(login, email, password);
expect(state.session.info?.webId).toBe(webId); expect(state.session.info?.webId).toBe(webId);
}); });
@ -168,10 +167,10 @@ describe('A Solid server with IDP', (): void => {
it('can log in again.', async(): Promise<void> => { it('can log in again.', async(): Promise<void> => {
const url = await state.startSession(); const url = await state.startSession();
const form = await state.extractFormUrl(url); let res = await state.fetchIdp(url);
expect(form.url.endsWith('/confirm')).toBe(true); expect(res.status).toBe(200);
const res = await state.fetchIdp(form.url, 'POST', '', APPLICATION_X_WWW_FORM_URLENCODED); res = await state.fetchIdp(url, 'POST', '', APPLICATION_X_WWW_FORM_URLENCODED);
const nextUrl = res.headers.get('location'); const nextUrl = res.headers.get('location');
expect(typeof nextUrl).toBe('string'); expect(typeof nextUrl).toBe('string');
@ -226,16 +225,12 @@ describe('A Solid server with IDP', (): void => {
state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer); state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer);
}); });
it('initializes the session.', async(): Promise<void> => {
const url = await state.startSession();
const { login } = await state.parseLoginPage(url);
expect(typeof login).toBe('string');
nextUrl = login;
});
it('can not log in with the old password anymore.', async(): Promise<void> => { it('can not log in with the old password anymore.', async(): Promise<void> => {
const url = await state.startSession();
nextUrl = url;
await state.parseLoginPage(url);
const formData = stringify({ email, password }); const formData = stringify({ email, password });
const res = await state.fetchIdp(nextUrl, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED); const res = await state.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED);
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(await res.text()).toContain('Incorrect password'); expect(await res.text()).toContain('Incorrect password');
}); });
@ -307,9 +302,8 @@ describe('A Solid server with IDP', (): void => {
it('initializes the session and logs in.', async(): Promise<void> => { it('initializes the session and logs in.', async(): Promise<void> => {
state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer); state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer);
const url = await state.startSession(); const url = await state.startSession();
const { login } = await state.parseLoginPage(url); await state.parseLoginPage(url);
expect(typeof login).toBe('string'); await state.login(url, newMail, password);
await state.login(login, newMail, password);
expect(state.session.info?.webId).toBe(newWebId); expect(state.session.info?.webId).toBe(newWebId);
}); });

View File

@ -94,15 +94,14 @@ export class IdentityTestState {
return nextUrl; return nextUrl;
} }
public async parseLoginPage(url: string): Promise<{ register: string; login: string; forgotPassword: string }> { public async parseLoginPage(url: string): Promise<{ register: string; forgotPassword: string }> {
const res = await this.fetchIdp(url); const res = await this.fetchIdp(url);
expect(res.status).toBe(200); expect(res.status).toBe(200);
const text = await res.text(); const text = await res.text();
const register = this.extractUrl(text, 'a:contains("Sign up")', 'href'); const register = this.extractUrl(text, 'a:contains("Sign up")', 'href');
const login = this.extractUrl(text, 'form', 'action');
const forgotPassword = this.extractUrl(text, 'a:contains("Forgot password")', 'href'); const forgotPassword = this.extractUrl(text, 'a:contains("Forgot password")', 'href');
return { register, login, forgotPassword }; return { register, forgotPassword };
} }
/** /**
@ -118,21 +117,6 @@ export class IdentityTestState {
return this.handleLoginRedirect(nextUrl); return this.handleLoginRedirect(nextUrl);
} }
/**
* Calls the given URL and extracts the action URL from a form contained within the resulting body.
* Also returns the resulting body in case further parsing is needed.
*/
public async extractFormUrl(url: string): Promise<{ url: string; body: string }> {
const res = await this.fetchIdp(url);
expect(res.status).toBe(200);
const text = await res.text();
const formUrl = this.extractUrl(text, 'form', 'action');
return {
url: new URL(formUrl, this.baseUrl).href,
body: text,
};
}
/** /**
* Handles the redirect that happens after logging in. * Handles the redirect that happens after logging in.
*/ */

View File

@ -30,7 +30,7 @@ import { readableToString } from '../../../src/util/StreamUtil';
import { CONTENT_TYPE, SOLID_HTTP, SOLID_META } from '../../../src/util/Vocabularies'; import { CONTENT_TYPE, SOLID_HTTP, SOLID_META } from '../../../src/util/Vocabularies';
describe('An IdentityProviderHttpHandler', (): void => { describe('An IdentityProviderHttpHandler', (): void => {
const apiVersion = '0.1'; const apiVersion = '0.2';
const baseUrl = 'http://test.com/'; const baseUrl = 'http://test.com/';
const idpPath = '/idp'; const idpPath = '/idp';
let request: HttpRequest; let request: HttpRequest;
@ -38,6 +38,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
let requestParser: jest.Mocked<RequestParser>; let requestParser: jest.Mocked<RequestParser>;
let providerFactory: jest.Mocked<ProviderFactory>; let providerFactory: jest.Mocked<ProviderFactory>;
let routes: { response: InteractionRoute; complete: InteractionRoute }; let routes: { response: InteractionRoute; complete: InteractionRoute };
let controls: Record<string, string>;
let interactionCompleter: jest.Mocked<InteractionCompleter>; let interactionCompleter: jest.Mocked<InteractionCompleter>;
let converter: jest.Mocked<RepresentationConverter>; let converter: jest.Mocked<RepresentationConverter>;
let errorHandler: jest.Mocked<ErrorHandler>; let errorHandler: jest.Mocked<ErrorHandler>;
@ -74,16 +75,18 @@ describe('An IdentityProviderHttpHandler', (): void => {
]; ];
routes = { routes = {
response: new InteractionRoute('/routeResponse', response: new InteractionRoute('^/routeResponse$',
{ 'text/html': '/view1' }, { 'text/html': '/view1' },
handlers[0], handlers[0],
'login', 'login',
{ 'text/html': '/response1' }), { 'text/html': '/response1' },
complete: new InteractionRoute('/routeComplete', { response: '/routeResponse' }),
complete: new InteractionRoute('^/routeComplete$',
{ 'text/html': '/view2' }, { 'text/html': '/view2' },
handlers[1], handlers[1],
'other'), 'other'),
}; };
controls = { response: 'http://test.com/idp/routeResponse' };
converter = { converter = {
handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => { handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => {
@ -129,7 +132,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0]; const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
expect(mockResponse).toBe(response); expect(mockResponse).toBe(response);
expect(JSON.parse(await readableToString(result.data!))) expect(JSON.parse(await readableToString(result.data!)))
.toEqual({ apiVersion, errorMessage: '', prefilled: {}, authenticating: false }); .toEqual({ apiVersion, errorMessage: '', prefilled: {}, authenticating: false, controls });
expect(result.statusCode).toBe(200); expect(result.statusCode).toBe(200);
expect(result.metadata?.contentType).toBe('text/html'); expect(result.metadata?.contentType).toBe('text/html');
expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.viewTemplates['text/html']); expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.viewTemplates['text/html']);
@ -147,7 +150,8 @@ describe('An IdentityProviderHttpHandler', (): void => {
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0]; const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
expect(mockResponse).toBe(response); expect(mockResponse).toBe(response);
expect(JSON.parse(await readableToString(result.data!))).toEqual({ apiVersion, key: 'val', authenticating: false }); expect(JSON.parse(await readableToString(result.data!)))
.toEqual({ apiVersion, key: 'val', authenticating: false, controls });
expect(result.statusCode).toBe(200); expect(result.statusCode).toBe(200);
expect(result.metadata?.contentType).toBe('text/html'); expect(result.metadata?.contentType).toBe('text/html');
expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.responseTemplates['text/html']); expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.responseTemplates['text/html']);
@ -163,7 +167,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
const { result } = responseWriter.handleSafe.mock.calls[0][0]; const { result } = responseWriter.handleSafe.mock.calls[0][0];
expect(JSON.parse(await readableToString(result.data!))).toEqual({ apiVersion, authenticating: true }); expect(JSON.parse(await readableToString(result.data!))).toEqual({ apiVersion, authenticating: true, controls });
}); });
it('errors for InteractionCompleteResults if no oidcInteraction is defined.', async(): Promise<void> => { it('errors for InteractionCompleteResults if no oidcInteraction is defined.', async(): Promise<void> => {
@ -231,8 +235,9 @@ describe('An IdentityProviderHttpHandler', (): void => {
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0]; const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
expect(mockResponse).toBe(response); expect(mockResponse).toBe(response);
expect(JSON.parse(await readableToString(result.data!))) expect(JSON.parse(await readableToString(result.data!))).toEqual(
.toEqual({ apiVersion, errorMessage: 'handle error', prefilled: { name: 'name' }, authenticating: false }); { apiVersion, errorMessage: 'handle error', prefilled: { name: 'name' }, authenticating: false, controls },
);
expect(result.statusCode).toBe(200); expect(result.statusCode).toBe(200);
expect(result.metadata?.contentType).toBe('text/html'); expect(result.metadata?.contentType).toBe('text/html');
expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.viewTemplates['text/html']); expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.viewTemplates['text/html']);
@ -248,7 +253,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0]; const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
expect(mockResponse).toBe(response); expect(mockResponse).toBe(response);
expect(JSON.parse(await readableToString(result.data!))) expect(JSON.parse(await readableToString(result.data!)))
.toEqual({ apiVersion, errorMessage: 'handle error', prefilled: {}, authenticating: false }); .toEqual({ apiVersion, errorMessage: 'handle error', prefilled: {}, authenticating: false, controls });
}); });
it('calls the errorHandler if there is a problem resolving the request.', async(): Promise<void> => { it('calls the errorHandler if there is a problem resolving the request.', async(): Promise<void> => {