feat: Update IDP templates to work with new API format

This commit is contained in:
Joachim Van Herwegen 2021-12-03 11:46:37 +01:00
parent bc0eeb1012
commit a684b2ead7
36 changed files with 645 additions and 189 deletions

View File

@ -1,6 +1,6 @@
MIT License
Copyright © 20192021 Inrupt Inc. and imec
Copyright © 20192022 Inrupt Inc. and imec
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -12,17 +12,22 @@
"StaticAssetHandler:_assets_value": "@css:templates/images/favicon.ico"
},
{
"StaticAssetHandler:_assets_key": "/.well_known/css/styles/",
"StaticAssetHandler:_assets_key": "/.well-known/css/styles/",
"StaticAssetHandler:_assets_value": "@css:templates/styles/"
},
{
"StaticAssetHandler:_assets_key": "/.well_known/css/fonts/",
"StaticAssetHandler:_assets_key": "/.well-known/css/fonts/",
"StaticAssetHandler:_assets_value": "@css:templates/fonts/"
},
{
"StaticAssetHandler:_assets_key": "/.well_known/css/images/",
"StaticAssetHandler:_assets_key": "/.well-known/css/images/",
"StaticAssetHandler:_assets_value": "@css:templates/images/"
},
{
"StaticAssetHandler:_assets_key": "/.well-known/css/scripts/",
"StaticAssetHandler:_assets_value": "@css:templates/scripts/"
}
]
}
]

View File

@ -3,6 +3,7 @@
"import": [
"files-scs:config/identity/handler/interaction/routes/existing-login.json",
"files-scs:config/identity/handler/interaction/routes/forgot-password.json",
"files-scs:config/identity/handler/interaction/routes/index.json",
"files-scs:config/identity/handler/interaction/routes/login.json",
"files-scs:config/identity/handler/interaction/routes/prompt.json",
"files-scs:config/identity/handler/interaction/routes/reset-password.json",
@ -21,7 +22,18 @@
{
"comment": "Adds controls and API version to JSON responses.",
"@id": "urn:solid-server:auth:password:ControlHandler",
"ControlHandler:_source" : {
"ControlHandler:_source" : { "@id": "urn:solid-server:auth:password:LocationInteractionHandler" }
}
]
},
{
"comment": "Converts redirect errors to location JSON responses.",
"@id": "urn:solid-server:auth:password:LocationInteractionHandler",
"@type": "LocationInteractionHandler",
"LocationInteractionHandler:_source" : { "@id": "urn:solid-server:auth:password:RouteInteractionHandler" }
},
{
"comment": "Handles every interaction based on their route.",
"@id": "urn:solid-server:auth:password:RouteInteractionHandler",
"@type": "WaterfallHandler",
"handlers": [
@ -32,6 +44,7 @@
],
"@type": "UnsupportedAsyncHandler"
},
{ "@id": "urn:solid-server:auth:password:IndexRoute" },
{ "@id": "urn:solid-server:auth:password:PromptRoute" },
{ "@id": "urn:solid-server:auth:password:LoginRoute" },
{ "@id": "urn:solid-server:auth:password:ExistingLoginRoute" },
@ -39,8 +52,5 @@
{ "@id": "urn:solid-server:auth:password:ResetPasswordRoute" }
]
}
}
]
}
]
}

View File

@ -5,8 +5,8 @@
"comment": "Handles the interaction that occurs when a logged in user wants to authenticate with a new app.",
"@id": "urn:solid-server:auth:password:ExistingLoginRoute",
"@type": "RelativeInteractionRoute",
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
"relativePath": "/idp/consent/",
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
"relativePath": "/consent/",
"source": {
"@type": "ExistingLoginHandler",
"interactionCompleter": { "@type": "BaseInteractionCompleter" }

View File

@ -5,8 +5,8 @@
"comment": "Handles the forgot password interaction",
"@id": "urn:solid-server:auth:password:ForgotPasswordRoute",
"@type": "RelativeInteractionRoute",
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
"relativePath": "/idp/forgotpassword/",
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
"relativePath": "/forgotpassword/",
"source": {
"@type": "ForgotPasswordHandler",
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },

View File

@ -0,0 +1,16 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Root API entry. Returns an empty body so we can add controls pointing to other interaction routes.",
"@id": "urn:solid-server:auth:password:IndexRoute",
"@type": "RelativeInteractionRoute",
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
"relativePath": "/idp/",
"source": {
"@type": "FixedInteractionHandler",
"response": {}
}
}
]
}

View File

@ -5,8 +5,8 @@
"comment": "Handles the login interaction",
"@id": "urn:solid-server:auth:password:LoginRoute",
"@type": "RelativeInteractionRoute",
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
"relativePath": "/idp/login/",
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
"relativePath": "/login/",
"source": {
"@type": "LoginHandler",
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },

View File

@ -5,10 +5,11 @@
"comment": "Handles OIDC redirects containing a prompt, such as login or consent.",
"@id": "urn:solid-server:auth:password:PromptRoute",
"@type": "RelativeInteractionRoute",
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
"relativePath": "/idp/",
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
"relativePath": "/prompt/",
"source": {
"@type": "PromptHandler",
"@id": "urn:solid-server:auth:password:PromptHandler",
"promptRoutes": [
{
"PromptHandler:_promptRoutes_key": "login",

View File

@ -5,8 +5,8 @@
"comment": "Handles the reset password interaction",
"@id": "urn:solid-server:auth:password:ResetPasswordRoute",
"@type": "RelativeInteractionRoute",
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
"relativePath": "/idp/resetpassword/",
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
"relativePath": "/resetpassword/",
"source": {
"@type": "ResetPasswordHandler",
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }

View File

@ -5,6 +5,14 @@
"@id": "urn:solid-server:auth:password:ControlHandler",
"@type": "ControlHandler",
"controls": [
{
"ControlHandler:_controls_key": "index",
"ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:IndexRoute" }
},
{
"ControlHandler:_controls_key": "prompt",
"ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:PromptRoute" }
},
{
"ControlHandler:_controls_key": "login",
"ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:LoginRoute" }

View File

@ -4,6 +4,7 @@
{
"@id": "urn:solid-server:auth:password:HtmlViewHandler",
"@type": "HtmlViewHandler",
"index": { "@id": "urn:solid-server:auth:password:IndexRoute" },
"templateEngine": {
"comment": "Renders the specific page and embeds it into the main HTML body.",
"@type": "ChainedTemplateEngine",

View File

@ -11,7 +11,7 @@
"args_adapterFactory": { "@id": "urn:solid-server:default:IdpAdapterFactory" },
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"args_oidcPath": "/.oidc",
"args_idpPath": "/idp",
"args_interactionHandler": { "@id": "urn:solid-server:auth:password:PromptHandler" },
"args_storage": { "@id": "urn:solid-server:default:IdpKeyStorage" },
"args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" },
"args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" },

View File

@ -32,6 +32,14 @@
{
"HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/register.html.ejs",
"HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:RegistrationRoute" }
},
{
"HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/reset-password-response.html.ejs",
"HtmlViewHandler:_templates_value": {
"@type": "RelativeInteractionRoute",
"base": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" },
"relativePath": "/response/"
}
}
]
}

View File

@ -5,8 +5,8 @@
"comment": "Handles the register interaction",
"@id": "urn:solid-server:auth:password:RegistrationRoute",
"@type": "RelativeInteractionRoute",
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
"relativePath": "/idp/register/",
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
"relativePath": "/register/",
"source": {
"@type": "RegistrationHandler",
"registrationManager": {

View File

@ -0,0 +1,26 @@
/* eslint-disable tsdoc/syntax */
// tsdoc/syntax cannot handle `@range`
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import type { Representation } from '../../http/representation/Representation';
import { APPLICATION_JSON } from '../../util/ContentTypes';
import type { InteractionHandlerInput } from './InteractionHandler';
import { InteractionHandler } from './InteractionHandler';
/**
* An {@link InteractionHandler} that always returns the same JSON response on all requests.
*/
export class FixedInteractionHandler extends InteractionHandler {
private readonly response: string;
/**
* @param response - @range {json}
*/
public constructor(response: unknown) {
super();
this.response = JSON.stringify(response);
}
public async handle({ operation }: InteractionHandlerInput): Promise<Representation> {
return new BasicRepresentation(this.response, operation.target, APPLICATION_JSON);
}
}

View File

@ -18,13 +18,19 @@ import type { InteractionRoute } from './routing/InteractionRoute';
* Will only handle GET operations for which there is a matching template if HTML is more preferred than JSON.
* Reason for doing it like this instead of a standard content negotiation flow
* is because we only want to return the HTML pages on GET requests. *
*
* Templates will receive the parameter `idpIndex` in their context pointing to the root index URL of the IDP API
* and an `authenticating` parameter indicating if this is an active OIDC interaction.
*/
export class HtmlViewHandler extends InteractionHandler {
private readonly idpIndex: string;
private readonly templateEngine: TemplateEngine;
private readonly templates: Record<string, string>;
public constructor(templateEngine: TemplateEngine, templates: Record<string, InteractionRoute>) {
public constructor(index: InteractionRoute, templateEngine: TemplateEngine,
templates: Record<string, InteractionRoute>) {
super();
this.idpIndex = index.getPath();
this.templateEngine = templateEngine;
this.templates = Object.fromEntries(
Object.entries(templates).map(([ template, route ]): [ string, string ] => [ route.getPath(), template ]),
@ -46,9 +52,10 @@ export class HtmlViewHandler extends InteractionHandler {
}
}
public async handle({ operation }: InteractionHandlerInput): Promise<Representation> {
public async handle({ operation, oidcInteraction }: InteractionHandlerInput): Promise<Representation> {
const template = this.templates[operation.target.path];
const result = await this.templateEngine.render({}, { templateFile: template });
const contents = { idpIndex: this.idpIndex, authenticating: Boolean(oidcInteraction) };
const result = await this.templateEngine.render(contents, { templateFile: template });
return new BasicRepresentation(result, operation.target, TEXT_HTML);
}
}

View File

@ -0,0 +1,40 @@
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import type { Representation } from '../../http/representation/Representation';
import { APPLICATION_JSON } from '../../util/ContentTypes';
import { RedirectHttpError } from '../../util/errors/RedirectHttpError';
import type { InteractionHandlerInput } from './InteractionHandler';
import { InteractionHandler } from './InteractionHandler';
/**
* Catches redirect errors from the source and returns a JSON body containing a `location` field instead.
* This allows the API to be used more easily from the browser.
*
* The issue is that if the API actually did a redirect,
* this would make it unusable when using it on HTML pages that need to render errors in case the fetch fails,
* but want to redirect the page in case it succeeds.
* See full overview at https://github.com/solid/community-server/pull/1088.
*/
export class LocationInteractionHandler extends InteractionHandler {
private readonly source: InteractionHandler;
public constructor(source: InteractionHandler) {
super();
this.source = source;
}
public async canHandle(input: InteractionHandlerInput): Promise<void> {
await this.source.canHandle(input);
}
public async handle(input: InteractionHandlerInput): Promise<Representation> {
try {
return await this.source.handle(input);
} catch (error: unknown) {
if (RedirectHttpError.isInstance(error)) {
const body = JSON.stringify({ location: error.location });
return new BasicRepresentation(body, input.operation.target, APPLICATION_JSON);
}
throw error;
}
}
}

View File

@ -160,8 +160,10 @@ export * from './identity/interaction/BaseInteractionHandler';
export * from './identity/interaction/CompletingInteractionHandler';
export * from './identity/interaction/ExistingLoginHandler';
export * from './identity/interaction/ControlHandler';
export * from './identity/interaction/FixedInteractionHandler';
export * from './identity/interaction/HtmlViewHandler';
export * from './identity/interaction/InteractionHandler';
export * from './identity/interaction/LocationInteractionHandler';
export * from './identity/interaction/PromptHandler';
// Identity/Ownership

View File

@ -1,9 +1,7 @@
<h1>Authorize</h1>
<p>You are authorizing an application to access your Pod.</p>
<form method="post">
<% if (locals.message) { %>
<p class="error"><%= message %></p>
<% } %>
<form method="post" id="mainForm">
<p class="error" id="error"></p>
<fieldset>
<ol>
@ -15,3 +13,7 @@
<p class="actions"><button autofocus type="submit" name="submit">Continue</button></p>
</form>
<script>
addPostListener('mainForm', 'error', '', () => { throw new Error('Expected a location field in the response.') });
</script>

View File

@ -1,13 +0,0 @@
<h1>Email sent</h1>
<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="<%= controls.login %>">Back to Log In</a></p>
<p class="actions">
<button type="submit" name="submit" class="link">Send Another Email</button>
</p>
</form>

View File

@ -1,19 +1,45 @@
<h1>Forgot password</h1>
<form method="post">
<% if (locals.message) { %>
<p class="error"><%= message %></p>
<% } %>
<div id="input-partial">
<h1>Forgot password</h1>
<form method="post" id="mainForm">
<p class="error" id="error"></p>
<fieldset>
<ol>
<li>
<label for="email">Email</label>
<input id="email" type="email" name="email" autofocus>
<label for="input-email">Email</label>
<input id="input-email" type="email" name="email" autofocus>
</li>
</ol>
</fieldset>
<p class="actions"><button type="submit" name="submit">Send recovery email</button></p>
<p class="actions"><a href="<%= controls.login %>" class="link">Log in</a></p>
</form>
<p class="actions"><a id="input-login-link" href="" class="link">Log in</a></p>
</form>
</div>
<div id="response-partial">
<h1>Email sent</h1>
<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 try sending another email.</p>
<ul class="actions">
<li><a id="response-login-link" href="" class="link">Back to Log In</a></li>
<li><a id="response-forgot-link" href="" class="link">Back to Forgot Password</a></li>
</ul>
</div>
<script>
addControlLinks('<%= idpIndex %>', {
'input-login-link': 'login',
'response-login-link': 'login',
'response-forgot-link': 'forgotPassword'
});
setVisibility('response-partial', false);
function updateResponse() {
// Swap visibility
setVisibility('input-partial', false);
setVisibility('response-partial', true);
}
addPostListener('mainForm', 'error', '', updateResponse);
</script>

View File

@ -1,17 +1,14 @@
<h1>Log in</h1>
<form method="post">
<% prefilled = locals.prefilled || {}; %>
<% if (locals.message) { %>
<p class="error"><%= message %></p>
<% } %>
<div id="authenticating">
<h1>Log in</h1>
<form method="post" id="mainForm">
<p class="error" id="error"></p>
<fieldset>
<legend>Your account</legend>
<ol>
<li>
<label for="email">Email</label>
<input id="email" type="email" name="email" autofocus value="<%= prefilled.email || '' %>">
<input id="email" type="email" name="email" autofocus>
</li>
<li>
<label for="password">Password</label>
@ -26,7 +23,34 @@
<p class="actions"><button type="submit" name="submit">Log in</button></p>
<ul class="actions">
<li><a href="<%= controls.register %>" class="link">Sign up</a></li>
<li><a href="<%= controls.forgotPassword %>" class="link">Forgot password</a></li>
<li><a id="register-link" href="" class="link">Sign up</a></li>
<li><a id="forgot-link" href="" class="link">Forgot password</a></li>
</ul>
</form>
</form>
</div>
<div id="not-authenticating">
<h1>Please log in through an app</h1>
<p><strong>To log in and access documents, you need to use a Solid app.</strong></p>
<p>This server provides secure storage, but it is not a client app.</p>
<p>
Choose one of the
<a href="https://solidproject.org/apps" class="link">Solid apps</a>
to log in and browse Pods.
</p>
<p>
If you're developing an app yourself,
use a library such as
<a href="https://github.com/inrupt/solid-client-authn-js" class="link"><code>solid-client-authn-js</code></a>
to initiate an OIDC authentication flow.
</p>
</div>
<script>
setVisibility('authenticating', <%= Boolean(authenticating) %>);
setVisibility('not-authenticating', <%= !Boolean(authenticating) %>);
addPostListener('mainForm', 'error', '', () => { throw new Error('Expected a location field in the response.') });
addControlLinks('<%= idpIndex %>', { 'register-link': 'register', 'forgot-link': 'forgotPassword'});
</script>

View File

@ -165,32 +165,8 @@
}
}
// Checks whether the given element is visible
function isVisible(element) {
return !(elements[element] ?? element).classList.contains('hidden');
}
// Sets the visibility of the given element
function setVisibility(element, visible) {
// Show or hide the element
element = elements[element] ?? element;
element.classList[visible ? 'remove' : 'add']('hidden');
// Disable children of hidden elements,
// such that the browser does not expect input for them
for (const child of getDescendants(element)) {
if ('disabled' in child)
child.disabled = !visible;
}
}
// Obtains all children, grandchildren, etc. of the given element
function getDescendants(element) {
return [...element.querySelectorAll("*")];
}
// Prepare the form when the DOM is ready
window.addEventListener('DOMContentLoaded', (event) => {
addEventListener('DOMContentLoaded', (event) => {
synchronizeInputFields();
elements.mainForm.classList.add('loaded');
});

View File

@ -1,38 +1,54 @@
<% if (createPod) { %>
<div id="response-createPod">
<h2>Your new Pod</h2>
<p>
Your new Pod is located at <a href="<%= podBaseUrl %>" class="link"><%= podBaseUrl %></a>.
Your new Pod is located at <a id="response-podBaseUrl" href="" class="link"></a>.
<br>
You can store your documents and data there.
</p>
<% } %>
</div>
<% if (createWebId) { %>
<div id="response-createWebId">
<h2>Your new WebID</h2>
<p>
Your new WebID is <a href="<%= webId %>" class="link"><%= webId %></a>.
Your new WebID is <a id="response-createdWebId" href="" class="link"></a>.
<br>
You can use this identifier to interact with Solid pods and apps.
</p>
<% } %>
</div>
<% if (register) { %>
<div id="response-register">
<h2>Your new account</h2>
<p>
Via your email address <em><%= email %></em>,
<% if (authenticating) { %>
you can now <a href="<%= controls.login %>">log in</a>
<% } else { %>
Via your email address <em id="response-email"></em>,
this server lets you log in to Solid apps
<% } %>
with your WebID <a href="<%= webId %>" class="link"><%= webId %></a>
with your WebID <a id="response-registeredWebId" href="" class="link"></a>
</p>
<% if (!createWebId) { %>
<div id="response-registerWebId">
<p>
You will need to add the triple
<code><%= `<${webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${oidcIssuer}>.`%></code>
to your existing WebID document <em><%= webId %></em>
<code id="response-oidcIssuerTriple"></code>
to your existing WebID document <em id="response-existingWebId"></em>
to indicate that you trust this server as a login provider.
</p>
<% } %>
<% } %>
</div>
<p>
You can now <a id="response-login-link" href="">log in</a>.
</p>
</div>
<script>
function updateResponseFields(json) {
updateElement('response-podBaseUrl', json.podBaseUrl, { innerText: true, href: true });
updateElement('response-createdWebId', json.webId, { innerText: true, href: true });
updateElement('response-registeredWebId', json.webId, { innerText: true, href: true });
const triple = `<${json.webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${json.oidcIssuer}>.`;
updateElement('response-oidcIssuerTriple', triple, { innerText: true });
updateElement('response-existingWebId', json.webId, { innerText: true });
updateElement('response-email', json.email, { innerText: true });
setVisibility('response-createPod', json.createPod);
setVisibility('response-createWebId', json.createWebId);
setVisibility('response-registerWebId', !json.createWebId);
setVisibility('response-register', json.register);
updateElement('response-login-link', json.controls.login, { href: true });
}
</script>

View File

@ -1,7 +0,0 @@
<h1>You've been signed up</h1>
<p>
<strong>Welcome to Solid.</strong>
We wish you an exciting experience!
</p>
<%- include('./register-response-partial.html.ejs') %>

View File

@ -1,11 +1,31 @@
<h1>Sign up</h1>
<form method="post" id="mainForm">
<% if (locals.message) { %>
<p class="error">Error: <%= message %></p>
<% } %>
<div id="input-partial">
<h1>Sign up</h1>
<form method="post" id="mainForm">
<p class="error" id="error"></p>
<%- include('./register-partial.html.ejs', { allowRoot: false }) %>
<p class="actions"><button type="submit" name="submit">Sign up</button></p>
</form>
</form>
</div>
<div id="response-partial">
<h1>You've been signed up</h1>
<p>
<strong>Welcome to Solid.</strong>
We wish you an exciting experience!
</p>
<%- include('./register-response-partial.html.ejs') %>
</div>
<script>
setVisibility('response-partial', false);
function updateResponse(json) {
// Swap visibility
setVisibility('input-partial', false);
setVisibility('response-partial', true);
updateResponseFields(json);
}
addPostListener('mainForm', 'error', '', updateResponse);
</script>

View File

@ -1,2 +0,0 @@
<h1>Password reset</h1>
<p>Your password was successfully reset.</p>

View File

@ -1,8 +1,7 @@
<h1>Reset password</h1>
<form method="post">
<% if (locals.message) { %>
<p class="error"><%= message %></p>
<% } %>
<div id="input-partial">
<h1>Reset password</h1>
<form method="post" id="mainForm">
<p class="error" id="error"></p>
<fieldset>
<ol>
@ -19,10 +18,23 @@
</fieldset>
<p class="actions"><button type="submit" name="submit">Reset password</button></p>
</form>
</form>
</div>
<div id="response-partial">
<h1>Password reset</h1>
<p>Your password was successfully reset.</p>
</div>
<script>
const hidden = document.getElementById('recordId');
const recordId = new URLSearchParams(window.location.search).get('rid');
const recordId = new URLSearchParams(location.search).get('rid');
hidden.value = recordId;
setVisibility('response-partial', false);
function updateResponse() {
// Swap visibility
setVisibility('input-partial', false);
setVisibility('response-partial', true);
}
addPostListener('mainForm', 'error', '', updateResponse);
</script>

View File

@ -4,11 +4,12 @@
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title><%= extractTitle(htmlBody) %></title>
<link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css">
<link rel="stylesheet" href="/.well-known/css/styles/main.css" type="text/css">
<script type="text/javascript" src="/.well-known/css/scripts/util.js"></script>
</head>
<body>
<header>
<a href="/"><img src="/.well_known/css/images/solid.svg" alt="[Solid logo]" /></a>
<a href="/"><img src="/.well-known/css/images/solid.svg" alt="[Solid logo]" /></a>
<h1>Community Solid Server</h1>
</header>
<main>
@ -16,7 +17,7 @@
</main>
<footer>
<p>
©20192021 <a href="https://inrupt.com/">Inrupt Inc.</a>
©20192022 <a href="https://inrupt.com/">Inrupt Inc.</a>
and <a href="https://www.imec-int.com/">imec</a>
</p>
</footer>

View File

@ -4,11 +4,11 @@
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Community Solid Server</title>
<link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css">
<link rel="stylesheet" href="/.well-known/css/styles/main.css" type="text/css">
</head>
<body>
<header>
<a href="/"><img src="/.well_known/css/images/solid.svg" alt="[Solid logo]" /></a>
<a href="/"><img src="/.well-known/css/images/solid.svg" alt="[Solid logo]" /></a>
<h1>Community Solid Server</h1>
</header>
<main>
@ -58,7 +58,7 @@
</main>
<footer>
<p>
©20192021 <a href="https://inrupt.com/">Inrupt Inc.</a>
©20192022 <a href="https://inrupt.com/">Inrupt Inc.</a>
and <a href="https://www.imec-int.com/">imec</a>
</p>
</footer>

142
templates/scripts/util.js Normal file
View File

@ -0,0 +1,142 @@
/**
* Acquires all data from the given form and POSTs it as JSON to the target URL.
* In case of failure this function will throw an error.
* In case of success a parsed JSON body of the response will be returned,
* unless the body contains a `location` field,
* in that case the page will be redirected to that location.
*
* @param formId - ID of the form.
* @param target - Target URL to POST to. Defaults to the current URL.
* @returns {Promise<unknown>} - The response JSON.
*/
async function postJsonForm(formId, target = '') {
const form = document.getElementById(formId);
const formData = new FormData(form);
const res = await fetch(target, {
method: 'POST',
credentials: 'include',
headers: { 'accept': 'application/json', 'content-type': 'application/json' },
body: JSON.stringify(Object.fromEntries(formData)),
});
if (res.status >= 400) {
const error = await res.json();
throw new Error(`${error.statusCode} - ${error.name}: ${error.message}`)
} else if (res.status === 200 || res.status === 201) {
const body = await res.json();
if (body.location) {
location.href = body.location;
} else {
return body;
}
}
}
/**
* Redirects the page to the given target with the key/value pairs of the JSON body as query parameters.
* Controls will be deleted from the JSON to prevent very large URLs.
* `false` values will be deleted to prevent incorrect serializations to "false".
* @param json - JSON to convert.
* @param target - URL to redirect to.
*/
function redirectJsonResponse(json, target) {
// These would cause the URL to get very large, can be acquired later if needed
delete json.controls;
// Remove false parameters since these would be converted to "false" strings
for (const [key, val] of Object.entries(json)) {
if (typeof val === 'boolean' && !val) {
delete json[key];
}
}
const searchParams = new URLSearchParams(Object.entries(json));
location.href = `${target}?${searchParams.toString()}`;
}
/**
* Adds a listener to the given form to catch the form submission and do an API call instead.
* In case of an error, the inner text of the given error block will be updated with the message.
* In case of success the callback function will be called.
*
* @param formId - ID of the form.
* @param errorId - ID of the error block.
* @param apiTarget - Target URL to send the POST request to. Defaults to the current URL.
* @param callback - Callback function that will be called with the response JSON.
*/
async function addPostListener(formId, errorId, apiTarget, callback) {
const form = document.getElementById(formId);
const errorBlock = document.getElementById(errorId);
form.addEventListener('submit', async(event) => {
event.preventDefault();
try {
const json = await postJsonForm(formId, apiTarget);
callback(json);
} catch (error) {
errorBlock.innerText = error.message;
}
});
}
/**
* Updates links on a page based on the controls received from the API.
* @param url - API URL that will return the controls
* @param controlMap - Key/value map with keys being element IDs and values being the control field names.
*/
async function addControlLinks(url, controlMap) {
const json = await fetchJson(url);
for (let [ id, control ] of Object.entries(controlMap)) {
updateElement(id, json.controls[control], { href: true });
}
}
/**
* Shows or hides the given element.
* @param id - ID of the element.
* @param visible - If it should be visible.
*/
function setVisibility(id, visible) {
const element = document.getElementById(id);
element.classList[visible ? 'remove' : 'add']('hidden');
// Disable children of hidden elements,
// such that the browser does not expect input for them
for (const child of getDescendants(element)) {
if ('disabled' in child)
child.disabled = !visible;
}
}
/**
* Obtains all children, grandchildren, etc. of the given element.
* @param element - Element to get all descendants from.
*/
function getDescendants(element) {
return [...element.querySelectorAll("*")];
}
/**
* Updates the inner text and href field of an element.
* @param id - ID of the element.
* @param text - Text to put in the field(s).
* @param options - Indicates which fields should be updated.
* Keys should be `innerText` and/or `href`, values should be booleans.
*/
function updateElement(id, text, options) {
const element = document.getElementById(id);
if (options.innerText) {
element.innerText = text;
}
if (options.href) {
element.href = text;
}
}
/**
* Fetches JSON from the url and converts it to an object.
* @param url - URL to fetch JSON from.
*/
async function fetchJson(url) {
const res = await fetch(url, { headers: { accept: 'application/json' } });
return res.json();
}

View File

@ -17,5 +17,38 @@
<% } %>
<% if (registration) { %>
<%- include('../identity/email-password/register-response-partial.html.ejs', { authenticating: false }) %>
<% if (createPod) { %>
<h2>Your new Pod</h2>
<p>
Your new Pod is located at <a href="<%= podBaseUrl %>" class="link"><%= podBaseUrl %></a>.
<br>
You can store your documents and data there.
</p>
<% } %>
<% if (createWebId) { %>
<h2>Your new WebID</h2>
<p>
Your new WebID is <a href="<%= webId %>" class="link"><%= webId %></a>.
<br>
You can use this identifier to interact with Solid pods and apps.
</p>
<% } %>
<% if (register) { %>
<h2>Your new account</h2>
<p>
Via your email address <em><%= email %></em>,
this server lets you log in to Solid apps
with your WebID <a href="<%= webId %>" class="link"><%= webId %></a>
</p>
<% if (!createWebId) { %>
<p>
You will need to add the triple
<code><%= `<${webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${oidcIssuer}>.`%></code>
to your existing WebID document <em><%= webId %></em>
to indicate that you trust this server as a login provider.
</p>
<% } %>
<% } %>
<% } %>

View File

@ -233,11 +233,18 @@ form ul.actions > li {
margin-right: 1em;
}
/* Directly hide hidden elements. */
.hidden {
display: none;
}
/* Hide form elements with a sliding animation so users can track more easily what is happening. */
form.loaded * {
max-height: 1000px;
transition: max-height .2s;
}
form .hidden {
display: block;
max-height: 0;
overflow: hidden;
}

View File

@ -0,0 +1,15 @@
import type { Operation } from '../../../../src/http/Operation';
import { FixedInteractionHandler } from '../../../../src/identity/interaction/FixedInteractionHandler';
import { readJsonStream } from '../../../../src/util/StreamUtil';
describe('A FixedInteractionHandler', (): void => {
const json = { data: 'data' };
const operation: Operation = { target: { path: 'http://example.com/test/' }} as any;
const handler = new FixedInteractionHandler(json);
it('returns the given JSON as response.', async(): Promise<void> => {
const response = await handler.handle({ operation });
await expect(readJsonStream(response.data)).resolves.toEqual(json);
expect(response.metadata.contentType).toBe('application/json');
});
});

View File

@ -10,12 +10,18 @@ import { readableToString } from '../../../../src/util/StreamUtil';
import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine';
describe('An HtmlViewHandler', (): void => {
const idpIndex = 'http://example.com/idp/';
let index: InteractionRoute;
let operation: Operation;
let templates: Record<string, jest.Mocked<InteractionRoute>>;
let templateEngine: TemplateEngine;
let handler: HtmlViewHandler;
beforeEach(async(): Promise<void> => {
index = {
getPath: jest.fn().mockReturnValue(idpIndex),
} as any;
operation = {
method: 'GET',
target: { path: 'http://example.com/idp/login/' },
@ -32,7 +38,7 @@ describe('An HtmlViewHandler', (): void => {
render: jest.fn().mockReturnValue(Promise.resolve('<html>')),
};
handler = new HtmlViewHandler(templateEngine, templates);
handler = new HtmlViewHandler(index, templateEngine, templates);
});
it('rejects non-GET requests.', async(): Promise<void> => {
@ -64,5 +70,17 @@ describe('An HtmlViewHandler', (): void => {
const result = await handler.handle({ operation });
expect(result.metadata.contentType).toBe(TEXT_HTML);
await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(templateEngine.render).toHaveBeenCalledTimes(1);
expect(templateEngine.render)
.toHaveBeenLastCalledWith({ idpIndex, authenticating: false }, { templateFile: '/templates/login.html.ejs' });
});
it('sets authenticating to true if there is an active interaction.', async(): Promise<void> => {
const result = await handler.handle({ operation, oidcInteraction: {} as any });
expect(result.metadata.contentType).toBe(TEXT_HTML);
await expect(readableToString(result.data)).resolves.toBe('<html>');
expect(templateEngine.render).toHaveBeenCalledTimes(1);
expect(templateEngine.render)
.toHaveBeenLastCalledWith({ idpIndex, authenticating: true }, { templateFile: '/templates/login.html.ejs' });
});
});

View File

@ -0,0 +1,62 @@
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type {
InteractionHandler,
InteractionHandlerInput,
} from '../../../../src/identity/interaction/InteractionHandler';
import { LocationInteractionHandler } from '../../../../src/identity/interaction/LocationInteractionHandler';
import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
import { readJsonStream } from '../../../../src/util/StreamUtil';
describe('A LocationInteractionHandler', (): void => {
const representation = new BasicRepresentation();
const input: InteractionHandlerInput = {
operation: {
target: { path: 'http://example.com/target' },
preferences: {},
method: 'GET',
body: new BasicRepresentation(),
},
};
let source: jest.Mocked<InteractionHandler>;
let handler: LocationInteractionHandler;
beforeEach(async(): Promise<void> => {
source = {
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue(representation),
} as any;
handler = new LocationInteractionHandler(source);
});
it('calls the source canHandle function.', async(): Promise<void> => {
await expect(handler.canHandle(input)).resolves.toBeUndefined();
expect(source.canHandle).toHaveBeenCalledTimes(1);
expect(source.canHandle).toHaveBeenLastCalledWith(input);
source.canHandle.mockRejectedValueOnce(new Error('bad input'));
await expect(handler.canHandle(input)).rejects.toThrow('bad input');
});
it('returns the source output.', async(): Promise<void> => {
await expect(handler.handle(input)).resolves.toBe(representation);
expect(source.handle).toHaveBeenCalledTimes(1);
expect(source.handle).toHaveBeenLastCalledWith(input);
});
it('returns a location object in case of redirect errors.', async(): Promise<void> => {
const location = 'http://example.com/foo';
source.handle.mockRejectedValueOnce(new FoundHttpError(location));
const response = await handler.handle(input);
expect(response.metadata.identifier.value).toEqual(input.operation.target.path);
await expect(readJsonStream(response.data)).resolves.toEqual({ location });
});
it('rethrows non-redirect errors.', async(): Promise<void> => {
source.handle.mockRejectedValueOnce(new NotFoundHttpError());
await expect(handler.handle(input)).rejects.toThrow(NotFoundHttpError);
});
});