mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add controls to IDP response JSON
Controls are now used in templates to prevent IDP URL hardcoding
This commit is contained in:
parent
d68854a474
commit
32a182dde8
@ -14,6 +14,10 @@
|
||||
"InteractionRoute:_responseTemplates_key": "text/html",
|
||||
"InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/forgot-password-response.html.ejs"
|
||||
},
|
||||
"controls": {
|
||||
"InteractionRoute:_controls_key": "forgotPassword",
|
||||
"InteractionRoute:_controls_value": "/forgotpassword"
|
||||
},
|
||||
"handler": {
|
||||
"@type": "ForgotPasswordHandler",
|
||||
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
|
||||
|
@ -11,6 +11,10 @@
|
||||
"InteractionRoute:_viewTemplates_key": "text/html",
|
||||
"InteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/login.html.ejs"
|
||||
},
|
||||
"controls": {
|
||||
"InteractionRoute:_controls_key": "login",
|
||||
"InteractionRoute:_controls_value": "/login"
|
||||
},
|
||||
"handler": {
|
||||
"@type": "LoginHandler",
|
||||
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }
|
||||
|
@ -14,6 +14,10 @@
|
||||
"InteractionRoute:_responseTemplates_key": "text/html",
|
||||
"InteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/register-response.html.ejs"
|
||||
},
|
||||
"controls": {
|
||||
"InteractionRoute:_controls_key": "register",
|
||||
"InteractionRoute:_controls_value": "/register"
|
||||
},
|
||||
"handler": {
|
||||
"@type": "RegistrationHandler",
|
||||
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
|
@ -27,7 +27,8 @@ import type {
|
||||
import { IdpInteractionError } from './interaction/util/IdpInteractionError';
|
||||
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.
|
||||
@ -38,6 +39,7 @@ export class InteractionRoute {
|
||||
public readonly viewTemplates: Record<string, string>;
|
||||
public readonly prompt?: string;
|
||||
public readonly responseTemplates: Record<string, string>;
|
||||
public readonly controls: Record<string, string>;
|
||||
|
||||
/**
|
||||
* @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 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> = {}) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -113,6 +119,8 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
|
||||
private readonly converter: RepresentationConverter;
|
||||
private readonly interactionCompleter: InteractionCompleter;
|
||||
|
||||
private readonly controls: Record<string, string>;
|
||||
|
||||
public constructor(args: IdentityProviderHttpHandlerArgs) {
|
||||
// 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.
|
||||
@ -123,6 +131,11 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
|
||||
this.interactionRoutes = args.interactionRoutes;
|
||||
this.converter = args.converter;
|
||||
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>,
|
||||
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 json = {
|
||||
apiVersion: API_VERSION,
|
||||
...result.details,
|
||||
authenticating: Boolean(oidcInteraction),
|
||||
controls: this.controls,
|
||||
};
|
||||
const representation = new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON);
|
||||
|
||||
// Template metadata is required for conversion
|
||||
@ -272,4 +290,13 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
|
||||
|
||||
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) ]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
<h1>Authorize</h1>
|
||||
<p>You are authorizing an application to access your Pod.</p>
|
||||
<form action="/idp/confirm" method="post">
|
||||
<form method="post">
|
||||
<% if (errorMessage) { %>
|
||||
<p class="error"><%= errorMessage %></p>
|
||||
<% } %>
|
||||
|
@ -1,11 +1,11 @@
|
||||
<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 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 %>" />
|
||||
|
||||
<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">
|
||||
<button type="submit" name="submit" class="link">Send Another Email</button>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<h1>Forgot password</h1>
|
||||
<form action="/idp/forgotpassword" method="post">
|
||||
<form method="post">
|
||||
<% if (errorMessage) { %>
|
||||
<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"><a href="/idp/login" class="link">Log in</a></p>
|
||||
<p class="actions"><a href="<%= controls.login %>" class="link">Log in</a></p>
|
||||
</form>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<h1>Log in</h1>
|
||||
<form action="/idp/login" method="post">
|
||||
<form method="post">
|
||||
<% if (errorMessage) { %>
|
||||
<p class="error"><%= errorMessage %></p>
|
||||
<% } %>
|
||||
@ -24,7 +24,7 @@
|
||||
<p class="actions"><button type="submit" name="submit">Log in</button></p>
|
||||
|
||||
<ul class="actions">
|
||||
<li><a href="/idp/register" class="link">Sign up</a></li>
|
||||
<li><a href="/idp/forgotpassword" class="link">Forgot password</a></li>
|
||||
<li><a href="<%= controls.register %>" class="link">Sign up</a></li>
|
||||
<li><a href="<%= controls.forgotPassword %>" class="link">Forgot password</a></li>
|
||||
</ul>
|
||||
</form>
|
||||
|
@ -27,7 +27,7 @@
|
||||
<p>
|
||||
Via your email address <em><%= email %></em>,
|
||||
<% if (authenticating) { %>
|
||||
you can now <a href="./login">log in</a>
|
||||
you can now <a href="<%= controls.login %>">log in</a>
|
||||
<% } else { %>
|
||||
this server lets you log in to Solid apps
|
||||
<% } %>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<h1>Sign up</h1>
|
||||
<form action="/idp/register" method="post" id="mainForm">
|
||||
<form method="post" id="mainForm">
|
||||
<% const isBlankForm = !('email' in prefilled); %>
|
||||
|
||||
<% if (errorMessage) { %>
|
||||
|
@ -145,9 +145,8 @@ describe('A Solid server with IDP', (): void => {
|
||||
|
||||
it('initializes the session and logs in.', async(): Promise<void> => {
|
||||
const url = await state.startSession();
|
||||
const { login } = await state.parseLoginPage(url);
|
||||
expect(typeof login).toBe('string');
|
||||
await state.login(login, email, password);
|
||||
await state.parseLoginPage(url);
|
||||
await state.login(url, email, password);
|
||||
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> => {
|
||||
const url = await state.startSession();
|
||||
|
||||
const form = await state.extractFormUrl(url);
|
||||
expect(form.url.endsWith('/confirm')).toBe(true);
|
||||
let res = await state.fetchIdp(url);
|
||||
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');
|
||||
expect(typeof nextUrl).toBe('string');
|
||||
|
||||
@ -226,16 +225,12 @@ describe('A Solid server with IDP', (): void => {
|
||||
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> => {
|
||||
const url = await state.startSession();
|
||||
nextUrl = url;
|
||||
await state.parseLoginPage(url);
|
||||
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(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> => {
|
||||
state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer);
|
||||
const url = await state.startSession();
|
||||
const { login } = await state.parseLoginPage(url);
|
||||
expect(typeof login).toBe('string');
|
||||
await state.login(login, newMail, password);
|
||||
await state.parseLoginPage(url);
|
||||
await state.login(url, newMail, password);
|
||||
expect(state.session.info?.webId).toBe(newWebId);
|
||||
});
|
||||
|
||||
|
@ -94,15 +94,14 @@ export class IdentityTestState {
|
||||
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);
|
||||
expect(res.status).toBe(200);
|
||||
const text = await res.text();
|
||||
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');
|
||||
|
||||
return { register, login, forgotPassword };
|
||||
return { register, forgotPassword };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -118,21 +117,6 @@ export class IdentityTestState {
|
||||
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.
|
||||
*/
|
||||
|
@ -30,7 +30,7 @@ import { readableToString } from '../../../src/util/StreamUtil';
|
||||
import { CONTENT_TYPE, SOLID_HTTP, SOLID_META } from '../../../src/util/Vocabularies';
|
||||
|
||||
describe('An IdentityProviderHttpHandler', (): void => {
|
||||
const apiVersion = '0.1';
|
||||
const apiVersion = '0.2';
|
||||
const baseUrl = 'http://test.com/';
|
||||
const idpPath = '/idp';
|
||||
let request: HttpRequest;
|
||||
@ -38,6 +38,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
let requestParser: jest.Mocked<RequestParser>;
|
||||
let providerFactory: jest.Mocked<ProviderFactory>;
|
||||
let routes: { response: InteractionRoute; complete: InteractionRoute };
|
||||
let controls: Record<string, string>;
|
||||
let interactionCompleter: jest.Mocked<InteractionCompleter>;
|
||||
let converter: jest.Mocked<RepresentationConverter>;
|
||||
let errorHandler: jest.Mocked<ErrorHandler>;
|
||||
@ -74,16 +75,18 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
];
|
||||
|
||||
routes = {
|
||||
response: new InteractionRoute('/routeResponse',
|
||||
response: new InteractionRoute('^/routeResponse$',
|
||||
{ 'text/html': '/view1' },
|
||||
handlers[0],
|
||||
'login',
|
||||
{ 'text/html': '/response1' }),
|
||||
complete: new InteractionRoute('/routeComplete',
|
||||
{ 'text/html': '/response1' },
|
||||
{ response: '/routeResponse' }),
|
||||
complete: new InteractionRoute('^/routeComplete$',
|
||||
{ 'text/html': '/view2' },
|
||||
handlers[1],
|
||||
'other'),
|
||||
};
|
||||
controls = { response: 'http://test.com/idp/routeResponse' };
|
||||
|
||||
converter = {
|
||||
handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => {
|
||||
@ -129,7 +132,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
|
||||
expect(mockResponse).toBe(response);
|
||||
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.metadata?.contentType).toBe('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);
|
||||
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
|
||||
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.metadata?.contentType).toBe('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);
|
||||
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> => {
|
||||
@ -231,8 +235,9 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
|
||||
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
|
||||
expect(mockResponse).toBe(response);
|
||||
expect(JSON.parse(await readableToString(result.data!)))
|
||||
.toEqual({ apiVersion, errorMessage: 'handle error', prefilled: { name: 'name' }, authenticating: false });
|
||||
expect(JSON.parse(await readableToString(result.data!))).toEqual(
|
||||
{ apiVersion, errorMessage: 'handle error', prefilled: { name: 'name' }, authenticating: false, controls },
|
||||
);
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(result.metadata?.contentType).toBe('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];
|
||||
expect(mockResponse).toBe(response);
|
||||
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> => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user