feat: Combine pod creation with IDP registration

This commit is contained in:
Joachim Van Herwegen 2021-05-28 16:38:35 +02:00
parent 9bb42ddf0d
commit 4d7d939dc4
18 changed files with 700 additions and 255 deletions

View File

@ -8,12 +8,13 @@
"pathName": "^/idp/register/?$", "pathName": "^/idp/register/?$",
"postHandler": { "postHandler": {
"@type": "RegistrationHandler", "@type": "RegistrationHandler",
"args_ownershipValidator": { "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"@type": "TokenOwnershipValidator", "args_webIdSuffix": "/profile/card",
"storage": { "@id": "urn:solid-server:default:ExpiringIdpStorage" } "args_identifierGenerator": { "@id": "urn:solid-server:default:IdentifierGenerator" },
}, "args_ownershipValidator": { "@id": "urn:solid-server:auth:password:OwnershipValidator" },
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
"args_interactionCompleter": { "@id": "urn:solid-server:auth:password:InteractionCompleter" } "args_podManager": { "@id": "urn:solid-server:default:PodManager" },
"args_responseHandler": { "@id": "urn:solid-server:auth:password:RegisterResponseRenderHandler" }
}, },
"renderHandler": { "@id": "urn:solid-server:auth:password:RegisterRenderHandler" } "renderHandler": { "@id": "urn:solid-server:auth:password:RegisterRenderHandler" }
}, },
@ -24,6 +25,13 @@
"@type": "RenderEjsHandler", "@type": "RenderEjsHandler",
"templatePath": { "@id": "urn:solid-server:default:variable:idpTemplateFolder" }, "templatePath": { "@id": "urn:solid-server:default:variable:idpTemplateFolder" },
"templateFile": "./email-password-interaction/register.ejs" "templateFile": "./email-password-interaction/register.ejs"
},
{
"comment": "Renders the successful registration page",
"@id": "urn:solid-server:auth:password:RegisterResponseRenderHandler",
"@type": "RenderEjsHandler",
"templatePath": { "@id": "urn:solid-server:default:variable:idpTemplateFolder" },
"templateFile": "./email-password-interaction/registerResponse.ejs"
} }
] ]
} }

View File

@ -1,83 +1,241 @@
import assert from 'assert'; import assert from 'assert';
import urljoin from 'url-join';
import type { ResourceIdentifier } from '../../../../ldp/representation/ResourceIdentifier';
import { getLoggerFor } from '../../../../logging/LogUtil'; import { getLoggerFor } from '../../../../logging/LogUtil';
import type { IdentifierGenerator } from '../../../../pods/generate/IdentifierGenerator';
import type { PodManager } from '../../../../pods/PodManager';
import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import { HttpHandler } from '../../../../server/HttpHandler';
import type { HttpRequest } from '../../../../server/HttpRequest'; import type { HttpRequest } from '../../../../server/HttpRequest';
import type { InteractionHttpHandlerInput } from '../../InteractionHttpHandler'; import type { RenderHandler } from '../../../../server/util/RenderHandler';
import { InteractionHttpHandler } from '../../InteractionHttpHandler'; import type { OwnershipValidator } from '../../../ownership/OwnershipValidator';
import { getFormDataRequestBody } from '../../util/FormDataUtil'; import { getFormDataRequestBody } from '../../util/FormDataUtil';
import type { InteractionCompleter } from '../../util/InteractionCompleter';
import type { OwnershipValidator } from '../../util/OwnershipValidator';
import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil'; import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore'; import type { AccountStore } from '../storage/AccountStore';
const emailRegex = /^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/u; const emailRegex = /^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/u;
interface RegistrationHandlerArgs { export interface RegistrationHandlerArgs {
/**
* Used to set the `oidcIssuer` value of newly registered pods.
*/
baseUrl: string;
/**
* Appended to the generated pod identifier to create the corresponding WebID.
*/
webIdSuffix: string;
/**
* Generates identifiers for new pods.
*/
identifierGenerator: IdentifierGenerator;
/**
* Verifies the user is the owner of the WebID they provide.
*/
ownershipValidator: OwnershipValidator; ownershipValidator: OwnershipValidator;
/**
* Stores all the registered account information.
*/
accountStore: AccountStore; accountStore: AccountStore;
interactionCompleter: InteractionCompleter; /**
* Creates the new pods.
*/
podManager: PodManager;
/**
* Renders the response when registration is successful.
*/
responseHandler: RenderHandler<NodeJS.Dict<any>>;
} }
// Results when parsing the input form data /**
type ParseResult = { * All the parameters that will be parsed from a request.
* `data` contains all the raw values to potentially be used by pod templates.
*/
interface ParseResult {
email: string; email: string;
password: string; password?: string;
webId: string; podName?: string;
remember: boolean; webId?: string;
}; createWebId: boolean;
register: boolean;
createPod: boolean;
data: NodeJS.Dict<string>;
}
/** /**
* Handles the submission of the registration form. * This class handles the 3 potential steps of the registration process:
* Creates the user and logs them in if successful. * 1. Generating a new WebID.
* 2. Registering a WebID with the IDP.
* 3. Creating a new pod for a given WebID.
*
* All of these steps are optional and will be determined based on the input parameters of a request,
* with the following considerations:
* * At least one option needs to be chosen.
* * In case a new WebID needs to be created, the other 2 steps are obligatory.
* * Ownership will be verified when the WebID is provided.
* * When registering and creating a pod, the base URL will be used as oidcIssuer value.
*/ */
export class RegistrationHandler extends InteractionHttpHandler { export class RegistrationHandler extends HttpHandler {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
private readonly baseUrl: string;
private readonly webIdSuffix: string;
private readonly identifierGenerator: IdentifierGenerator;
private readonly ownershipValidator: OwnershipValidator; private readonly ownershipValidator: OwnershipValidator;
private readonly accountStore: AccountStore; private readonly accountStore: AccountStore;
private readonly interactionCompleter: InteractionCompleter; private readonly podManager: PodManager;
private readonly responseHandler: RenderHandler<NodeJS.Dict<any>>;
public constructor(args: RegistrationHandlerArgs) { public constructor(args: RegistrationHandlerArgs) {
super(); super();
this.baseUrl = args.baseUrl;
this.webIdSuffix = args.webIdSuffix;
this.identifierGenerator = args.identifierGenerator;
this.ownershipValidator = args.ownershipValidator; this.ownershipValidator = args.ownershipValidator;
this.accountStore = args.accountStore; this.accountStore = args.accountStore;
this.interactionCompleter = args.interactionCompleter; this.podManager = args.podManager;
this.responseHandler = args.responseHandler;
} }
public async handle(input: InteractionHttpHandlerInput): Promise<void> { public async handle({ request, response }: HttpHandlerInput): Promise<void> {
const { email, webId, password, remember } = await this.parseInput(input.request); const result = await this.parseInput(request);
try { try {
// Check if WebId contains required triples and register new account if successful const props = await this.register(result);
await this.ownershipValidator.handleSafe({ webId });
await this.accountStore.create(email, webId, password); await this.responseHandler.handleSafe({ response, props });
await this.interactionCompleter.handleSafe({ } catch (error: unknown) {
...input, throwIdpInteractionError(error, result.data as Record<string, string>);
webId,
shouldRemember: Boolean(remember),
});
this.logger.debug(`Registering agent ${email} with WebId ${webId}`);
} catch (err: unknown) {
throwIdpInteractionError(err, { email, webId });
} }
} }
/** /**
* Parses and validates the input form data. * Does the full registration and pod creation process,
* Will throw an {@link IdpInteractionError} in case something is wrong. * with the steps chosen by the values in the `ParseResult`.
* All relevant data that was correct up to that point will be prefilled. */
private async register(result: ParseResult): Promise<NodeJS.Dict<any>> {
// This is only used when createWebId and/or createPod are true
let podBaseUrl: ResourceIdentifier | undefined;
// Create or verify the WebID
if (result.createWebId) {
podBaseUrl = this.identifierGenerator.generate(result.podName!);
result.webId = urljoin(podBaseUrl.path, this.webIdSuffix);
} else {
await this.ownershipValidator.handleSafe({ webId: result.webId! });
}
// Register the account
if (result.register) {
await this.accountStore.create(result.email, result.webId!, result.password!);
// Add relevant data for the templates
result.data.oidcIssuer = this.baseUrl;
}
// Create the pod
if (result.createPod) {
podBaseUrl = podBaseUrl ?? this.identifierGenerator.generate(result.podName!);
try {
await this.podManager.createPod(podBaseUrl, { ...result.data, webId: result.webId! });
} catch (error: unknown) {
// In case pod creation errors we don't want to keep the account
if (result.register) {
await this.accountStore.deleteAccount(result.email);
}
throw error;
}
}
// Verify the account
if (result.register) {
// This prevents there being a small timeframe where the account can be used before the pod creation is finished.
// That timeframe could potentially be used by malicious users.
await this.accountStore.verify(result.email);
}
return {
webId: result.webId,
email: result.email,
oidcIssuer: this.baseUrl,
podBaseUrl: podBaseUrl?.path,
createWebId: result.createWebId,
register: result.register,
createPod: result.createPod,
};
}
/**
* Parses the input request into a `ParseResult`.
*/ */
private async parseInput(request: HttpRequest): Promise<ParseResult> { private async parseInput(request: HttpRequest): Promise<ParseResult> {
const prefilled: Record<string, string> = {}; const parsed = await getFormDataRequestBody(request);
let prefilled: Record<string, string> = {};
try { try {
const { email, webId, password, confirmPassword, remember } = await getFormDataRequestBody(request); for (const key of Object.keys(parsed)) {
assert(typeof email === 'string' && email.length > 0, 'Email required'); if (Array.isArray(parsed[key])) {
assert(emailRegex.test(email), 'Invalid email'); throw new Error(`Multiple values found for key ${key}`);
prefilled.email = email; }
assert(typeof webId === 'string' && webId.length > 0, 'WebId required'); }
prefilled.webId = webId; prefilled = parsed as Record<string, string>;
assertPassword(password, confirmPassword); return this.validateInput(prefilled);
return { email, password, webId, remember: Boolean(remember) };
} catch (err: unknown) { } catch (err: unknown) {
throwIdpInteractionError(err, prefilled); throwIdpInteractionError(err, prefilled);
} }
} }
/**
* Converts the raw input date into a `ParseResult`.
* Verifies that all the data combinations make sense.
*/
private validateInput(parsed: NodeJS.Dict<string>): ParseResult {
const { email, password, confirmPassword, podName, webId } = parsed;
assert(typeof email === 'string' && email.length > 0 && emailRegex.test(email),
'A valid e-mail address is required');
const result: ParseResult = {
email,
createWebId: Boolean(parsed.createWebId),
register: Boolean(parsed.register),
createPod: Boolean(parsed.createPod),
data: parsed,
};
const validWebId = typeof webId === 'string' && webId.length > 0;
if (result.createWebId) {
if (validWebId) {
throw new Error('A WebID should only be provided when no new one is being created');
}
} else {
if (!validWebId) {
throw new Error('A WebID is required if no new one is being created');
}
result.webId = webId;
}
if (result.register) {
assertPassword(password, confirmPassword);
result.password = password;
} else if (typeof password === 'string' && password.length > 0) {
throw new Error('A password should only be provided when registering');
}
if (result.createWebId || result.createPod) {
assert(typeof podName === 'string' && podName.length > 0,
'A pod name is required when creating a pod and/or WebID');
result.podName = podName;
} else if (typeof podName === 'string' && podName.length > 0) {
throw new Error('A pod name should only be provided when creating a pod and/or WebID');
}
if (result.createWebId && !(result.register && result.createPod)) {
throw new Error('Creating a WebID is only possible when also registering and creating a pod');
}
if (!result.createWebId && !result.register && !result.createPod) {
throw new Error('At least one option needs to be chosen');
}
return result;
}
} }

View File

@ -3,7 +3,6 @@ import { getLoggerFor } from '../logging/LogUtil';
import type { KeyValueStorage } from '../storage/keyvalue/KeyValueStorage'; import type { KeyValueStorage } from '../storage/keyvalue/KeyValueStorage';
import type { ResourceStore } from '../storage/ResourceStore'; import type { ResourceStore } from '../storage/ResourceStore';
import { addGeneratedResources } from './generate/GenerateUtil'; import { addGeneratedResources } from './generate/GenerateUtil';
import type { IdentifierGenerator } from './generate/IdentifierGenerator';
import type { PodGenerator } from './generate/PodGenerator'; import type { PodGenerator } from './generate/PodGenerator';
import type { ResourcesGenerator } from './generate/ResourcesGenerator'; import type { ResourcesGenerator } from './generate/ResourcesGenerator';
import type { PodManager } from './PodManager'; import type { PodManager } from './PodManager';
@ -22,27 +21,23 @@ import type { PodSettings } from './settings/PodSettings';
*/ */
export class ConfigPodManager implements PodManager { export class ConfigPodManager implements PodManager {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
private readonly idGenerator: IdentifierGenerator;
private readonly podGenerator: PodGenerator; private readonly podGenerator: PodGenerator;
private readonly routingStorage: KeyValueStorage<string, ResourceStore>; private readonly routingStorage: KeyValueStorage<string, ResourceStore>;
private readonly resourcesGenerator: ResourcesGenerator; private readonly resourcesGenerator: ResourcesGenerator;
/** /**
* @param idGenerator - Generator for the pod identifiers.
* @param podGenerator - Generator for the pod stores. * @param podGenerator - Generator for the pod stores.
* @param resourcesGenerator - Generator for the pod resources. * @param resourcesGenerator - Generator for the pod resources.
* @param routingStorage - Where to store the generated pods so they can be routed to. * @param routingStorage - Where to store the generated pods so they can be routed to.
*/ */
public constructor(idGenerator: IdentifierGenerator, podGenerator: PodGenerator, public constructor(podGenerator: PodGenerator, resourcesGenerator: ResourcesGenerator,
resourcesGenerator: ResourcesGenerator, routingStorage: KeyValueStorage<string, ResourceStore>) { routingStorage: KeyValueStorage<string, ResourceStore>) {
this.idGenerator = idGenerator;
this.podGenerator = podGenerator; this.podGenerator = podGenerator;
this.routingStorage = routingStorage; this.routingStorage = routingStorage;
this.resourcesGenerator = resourcesGenerator; this.resourcesGenerator = resourcesGenerator;
} }
public async createPod(settings: PodSettings): Promise<ResourceIdentifier> { public async createPod(identifier: ResourceIdentifier, settings: PodSettings): Promise<void> {
const identifier = this.idGenerator.generate(settings.login);
this.logger.info(`Creating pod ${identifier.path}`); this.logger.info(`Creating pod ${identifier.path}`);
// Will error in case there already is a store for the given identifier // Will error in case there already is a store for the given identifier
@ -52,7 +47,5 @@ export class ConfigPodManager implements PodManager {
this.logger.info(`Added ${count} resources to ${identifier.path}`); this.logger.info(`Added ${count} resources to ${identifier.path}`);
await this.routingStorage.set(identifier.path, store); await this.routingStorage.set(identifier.path, store);
return identifier;
} }
} }

View File

@ -3,7 +3,6 @@ import { getLoggerFor } from '../logging/LogUtil';
import type { ResourceStore } from '../storage/ResourceStore'; import type { ResourceStore } from '../storage/ResourceStore';
import { ConflictHttpError } from '../util/errors/ConflictHttpError'; import { ConflictHttpError } from '../util/errors/ConflictHttpError';
import { addGeneratedResources } from './generate/GenerateUtil'; import { addGeneratedResources } from './generate/GenerateUtil';
import type { IdentifierGenerator } from './generate/IdentifierGenerator';
import type { ResourcesGenerator } from './generate/ResourcesGenerator'; import type { ResourcesGenerator } from './generate/ResourcesGenerator';
import type { PodManager } from './PodManager'; import type { PodManager } from './PodManager';
import type { PodSettings } from './settings/PodSettings'; import type { PodSettings } from './settings/PodSettings';
@ -16,29 +15,24 @@ export class GeneratedPodManager implements PodManager {
protected readonly logger = getLoggerFor(this); protected readonly logger = getLoggerFor(this);
private readonly store: ResourceStore; private readonly store: ResourceStore;
private readonly idGenerator: IdentifierGenerator;
private readonly resourcesGenerator: ResourcesGenerator; private readonly resourcesGenerator: ResourcesGenerator;
public constructor(store: ResourceStore, idGenerator: IdentifierGenerator, resourcesGenerator: ResourcesGenerator) { public constructor(store: ResourceStore, resourcesGenerator: ResourcesGenerator) {
this.store = store; this.store = store;
this.idGenerator = idGenerator;
this.resourcesGenerator = resourcesGenerator; this.resourcesGenerator = resourcesGenerator;
} }
/** /**
* Creates a new pod, pre-populating it with the resources created by the data generator. * Creates a new pod, pre-populating it with the resources created by the data generator.
* Pod identifiers are created based on the identifier generator.
* Will throw an error if the given identifier already has a resource. * Will throw an error if the given identifier already has a resource.
*/ */
public async createPod(settings: PodSettings): Promise<ResourceIdentifier> { public async createPod(identifier: ResourceIdentifier, settings: PodSettings): Promise<void> {
const podIdentifier = this.idGenerator.generate(settings.login); this.logger.info(`Creating pod ${identifier.path}`);
this.logger.info(`Creating pod ${podIdentifier.path}`); if (await this.store.resourceExists(identifier)) {
if (await this.store.resourceExists(podIdentifier)) { throw new ConflictHttpError(`There already is a resource at ${identifier.path}`);
throw new ConflictHttpError(`There already is a resource at ${podIdentifier.path}`);
} }
const count = await addGeneratedResources(podIdentifier, settings, this.resourcesGenerator, this.store); const count = await addGeneratedResources(identifier, settings, this.resourcesGenerator, this.store);
this.logger.info(`Added ${count} resources to ${podIdentifier.path}`); this.logger.info(`Added ${count} resources to ${identifier.path}`);
return podIdentifier;
} }
} }

View File

@ -8,8 +8,8 @@ import type { PodSettings } from './settings/PodSettings';
export interface PodManager { export interface PodManager {
/** /**
* Creates a pod for the given settings. * Creates a pod for the given settings.
* @param identifier - Root identifier indicating where the pod should be created.
* @param settings - Settings describing the pod. * @param settings - Settings describing the pod.
* @returns {@link ResourceIdentifier} of the newly created pod.
*/ */
createPod: (settings: PodSettings) => Promise<ResourceIdentifier>; createPod: (identifier: ResourceIdentifier, settings: PodSettings) => Promise<void>;
} }

View File

@ -4,10 +4,6 @@
* they give an indication of what is sometimes expected. * they give an indication of what is sometimes expected.
*/ */
export interface PodSettings extends NodeJS.Dict<string> { export interface PodSettings extends NodeJS.Dict<string> {
/**
* The name of the pod. Will be used for generating the identifier.
*/
login: string;
/** /**
* The WebId of the owner of this pod. * The WebId of the owner of this pod.
*/ */
@ -29,8 +25,4 @@ export interface PodSettings extends NodeJS.Dict<string> {
* The OIDC issuer of the owner's WebId. * The OIDC issuer of the owner's WebId.
*/ */
oidcIssuer?: string; oidcIssuer?: string;
/**
* A registration token for linking the owner's WebId to an IDP.
*/
oidcIssuerRegistrationToken?: string;
} }

View File

@ -28,12 +28,7 @@
</div> </div>
<div class="input-wrap"> <div class="input-wrap">
<label for="webId">WebId:</label> <label for="password">Password (when registering with the IDP):</label>
<input id="webId" type="text" name="webId" <% if (prefilled.webId) { %> value="<%= prefilled.webId %>" <% } %> />
</div>
<div class="input-wrap">
<label for="password">Password:</label>
<input id="password" type="password" name="password" /> <input id="password" type="password" name="password" />
</div> </div>
@ -43,15 +38,28 @@
</div> </div>
<div class="input-wrap"> <div class="input-wrap">
<label class="checkbox"><input type="checkbox" name="remember" value="yes" checked>Stay signed in</label> <label for="podName">Pod name (when creating a pod):</label>
<input id="podName" type="text" name="podName" <% if (prefilled.podName) { %> value="<%= prefilled.podName %>" <% } %> />
</div> </div>
<button type="submit" name="submit" class="ids-link-filled">Create Identity</button> <div class="input-wrap">
<label for="webId">WebID (when not creating a WebID):</label>
<hr /> <input id="webId" type="text" name="webId" <% if (prefilled.webId) { %> value="<%= prefilled.webId %>" <% } %> />
<div class="space-between">
<a href="/idp/login" class="link">Sign In</a>
</div> </div>
<div class="input-wrap">
<label class="checkbox"><input type="checkbox" name="createWebId" checked>Create new WebID (also requires the other two options)</label>
</div>
<div class="input-wrap">
<label class="checkbox"><input type="checkbox" name="register" checked>Register your WebID with the IDP</label>
</div>
<div class="input-wrap">
<label class="checkbox"><input type="checkbox" name="createPod" checked>Create a new pod</label>
</div>
<button type="submit" name="submit" class="ids-link-filled">Submit</button>
</form> </form>
</div> </div>
</div> </div>

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Registration Succesful</title>
<link rel="stylesheet" href="/idp/style.css">
</head>
<body>
<div class="main-content">
<section class="main-content-section">
<div class="wrapper">
<h1 class="title--white">Registration Successful</h1>
<div class="login-panel">
<div class="panel-body">
<% if (createWebId) { %>
<p class="content__message">
Your new WebID is <a href="<%= webId %>" class="link"><%= webId %></a>
</p>
<% } %>
<% if (register) { %>
<p class="content__message">
You can now identify as <a href="<%= webId %>" class="link"><%= webId %></a> with our IDP using <%= email %>
</p>
<% if (!createWebId) { %>
<p class="content__message">
Make sure you add the triple
<%= `<${webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${oidcIssuer}>.`%>
to your WebID profile.
</p>
<% } %>
<% } %>
<% if (createPod) { %>
<p class="content__message">
Your new pod has been created and can be found at <a href="<%= podBaseUrl %>" class="link"><%= podBaseUrl %></a>
</p>
<% } %>
</div>
</div>
</div>
</section>
</div>
</body>
</html>

View File

@ -7,7 +7,6 @@
foaf:primaryTopic <{{webId}}>. foaf:primaryTopic <{{webId}}>.
<{{webId}}> <{{webId}}>
a foaf:Person; {{#if name}}foaf:name "{{name}}";{{/if}}
foaf:name "{{name}}"; {{#if oidcIssuer}}solid:oidcIssuer <{{oidcIssuer}}>;{{/if}}
solid:oidcIssuer <{{oidcIssuer}}> ; a foaf:Person.
solid:oidcIssuerRegistrationToken "{{oidcIssuerRegistrationToken}}" .

View File

@ -1,8 +1,10 @@
import { mkdirSync } from 'fs'; import { mkdirSync } from 'fs';
import type { Server } from 'http'; import type { Server } from 'http';
import { stringify } from 'querystring';
import fetch from 'cross-fetch'; import fetch from 'cross-fetch';
import type { Initializer } from '../../src/init/Initializer'; import type { Initializer } from '../../src/init/Initializer';
import type { HttpServerFactory } from '../../src/server/HttpServerFactory'; import type { HttpServerFactory } from '../../src/server/HttpServerFactory';
import type { WrappedExpiringStorage } from '../../src/storage/keyvalue/WrappedExpiringStorage';
import { joinFilePath } from '../../src/util/PathUtil'; import { joinFilePath } from '../../src/util/PathUtil';
import { getPort } from '../util/Util'; import { getPort } from '../util/Util';
import { getTestConfigPath, getTestFolder, instantiateFromConfig, removeFolder } from './Config'; import { getTestConfigPath, getTestFolder, instantiateFromConfig, removeFolder } from './Config';
@ -27,15 +29,16 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
let server: Server; let server: Server;
let initializer: Initializer; let initializer: Initializer;
let factory: HttpServerFactory; let factory: HttpServerFactory;
const agent = { login: 'alice', webId: 'http://test.com/#alice', name: 'Alice Bob', template }; let expiringStorage: WrappedExpiringStorage<any, any>;
const podUrl = `${baseUrl}${agent.login}/`; const settings = { podName: 'alice', webId: 'http://test.com/#alice', email: 'alice@test.email', template, createPod: true };
const podUrl = `${baseUrl}${settings.podName}/`;
beforeAll(async(): Promise<void> => { beforeAll(async(): Promise<void> => {
const variables: Record<string, any> = { const variables: Record<string, any> = {
'urn:solid-server:default:variable:baseUrl': baseUrl, 'urn:solid-server:default:variable:baseUrl': baseUrl,
'urn:solid-server:default:variable:port': port,
'urn:solid-server:default:variable:rootFilePath': rootFilePath, 'urn:solid-server:default:variable:rootFilePath': rootFilePath,
'urn:solid-server:default:variable:podConfigJson': podConfigJson, 'urn:solid-server:default:variable:podConfigJson': podConfigJson,
'urn:solid-server:default:variable:idpTemplateFolder': joinFilePath(__dirname, '../../templates/idp'),
}; };
// Need to make sure the temp folder exists so the podConfigJson can be written to it // Need to make sure the temp folder exists so the podConfigJson can be written to it
@ -47,7 +50,7 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
getTestConfigPath('server-dynamic-unsafe.json'), getTestConfigPath('server-dynamic-unsafe.json'),
variables, variables,
) as Record<string, any>; ) as Record<string, any>;
({ factory, initializer } = instances); ({ factory, initializer, expiringStorage } = instances);
// Set up the internal store // Set up the internal store
await initializer.handleSafe(); await initializer.handleSafe();
@ -56,6 +59,7 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
}); });
afterAll(async(): Promise<void> => { afterAll(async(): Promise<void> => {
expiringStorage.finalize();
await new Promise((resolve, reject): void => { await new Promise((resolve, reject): void => {
server.close((error): void => error ? reject(error) : resolve()); server.close((error): void => error ? reject(error) : resolve());
}); });
@ -63,15 +67,13 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
}); });
it('creates a pod with the given config.', async(): Promise<void> => { it('creates a pod with the given config.', async(): Promise<void> => {
const res = await fetch(`${baseUrl}pods`, { const res = await fetch(`${baseUrl}idp/register`, {
method: 'POST', method: 'POST',
headers: { headers: { 'content-type': 'application/x-www-form-urlencoded' },
'content-type': 'application/json', body: stringify(settings),
},
body: JSON.stringify(agent),
}); });
expect(res.status).toBe(201); expect(res.status).toBe(200);
expect(res.headers.get('location')).toBe(podUrl); await expect(res.text()).resolves.toContain(podUrl);
}); });
it('can fetch the created pod.', async(): Promise<void> => { it('can fetch the created pod.', async(): Promise<void> => {
@ -87,7 +89,7 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
it('should be able to read acl file with the correct credentials.', async(): Promise<void> => { it('should be able to read acl file with the correct credentials.', async(): Promise<void> => {
const res = await fetch(`${podUrl}.acl`, { const res = await fetch(`${podUrl}.acl`, {
headers: { headers: {
authorization: `WebID ${agent.webId}`, authorization: `WebID ${settings.webId}`,
}, },
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
@ -96,7 +98,7 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
it('should be able to write to the pod now as the owner.', async(): Promise<void> => { it('should be able to write to the pod now as the owner.', async(): Promise<void> => {
let res = await fetch(`${podUrl}test`, { let res = await fetch(`${podUrl}test`, {
headers: { headers: {
authorization: `WebID ${agent.webId}`, authorization: `WebID ${settings.webId}`,
}, },
}); });
expect(res.status).toBe(404); expect(res.status).toBe(404);
@ -104,7 +106,7 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
res = await fetch(`${podUrl}test`, { res = await fetch(`${podUrl}test`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
authorization: `WebID ${agent.webId}`, authorization: `WebID ${settings.webId}`,
'content-type': 'text/plain', 'content-type': 'text/plain',
}, },
body: 'this is new data!', body: 'this is new data!',
@ -113,7 +115,7 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
res = await fetch(`${podUrl}test`, { res = await fetch(`${podUrl}test`, {
headers: { headers: {
authorization: `WebID ${agent.webId}`, authorization: `WebID ${settings.webId}`,
}, },
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
@ -121,13 +123,13 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
}); });
it('should not be able to create a pod with the same name.', async(): Promise<void> => { it('should not be able to create a pod with the same name.', async(): Promise<void> => {
const res = await fetch(`${baseUrl}pods`, { const res = await fetch(`${baseUrl}idp/register`, {
method: 'POST', method: 'POST',
headers: { headers: { 'content-type': 'application/x-www-form-urlencoded' },
'content-type': 'application/json', body: stringify(settings),
},
body: JSON.stringify(agent),
}); });
expect(res.status).toBe(409); // 200 due to there only being a HTML solution right now that only returns 200
expect(res.status).toBe(200);
await expect(res.text()).resolves.toContain(`There already is a pod at ${podUrl}`);
}); });
}); });

View File

@ -2,7 +2,9 @@ import type { Server } from 'http';
import { stringify } from 'querystring'; import { stringify } from 'querystring';
import { URL } from 'url'; import { URL } from 'url';
import { load } from 'cheerio'; import { load } from 'cheerio';
import type { Response } from 'cross-fetch';
import { fetch } from 'cross-fetch'; import { fetch } from 'cross-fetch';
import urljoin from 'url-join';
import type { Initializer } from '../../src/init/Initializer'; import type { Initializer } from '../../src/init/Initializer';
import type { HttpServerFactory } from '../../src/server/HttpServerFactory'; import type { HttpServerFactory } from '../../src/server/HttpServerFactory';
import type { WrappedExpiringStorage } from '../../src/storage/keyvalue/WrappedExpiringStorage'; import type { WrappedExpiringStorage } from '../../src/storage/keyvalue/WrappedExpiringStorage';
@ -24,6 +26,14 @@ jest.mock('nodemailer');
// Prevent panva/node-openid-client from emitting DraftWarning // Prevent panva/node-openid-client from emitting DraftWarning
jest.spyOn(process, 'emitWarning').mockImplementation(); jest.spyOn(process, 'emitWarning').mockImplementation();
async function postForm(url: string, formBody: string): Promise<Response> {
return fetch(url, {
method: 'POST',
headers: { 'content-type': APPLICATION_X_WWW_FORM_URLENCODED },
body: formBody,
});
}
// No way around the cookies https://github.com/panva/node-oidc-provider/issues/552 . // No way around the cookies https://github.com/panva/node-oidc-provider/issues/552 .
// They will be simulated by storing the values and passing them along. // They will be simulated by storing the values and passing them along.
// This is why the redirects are handled manually. // This is why the redirects are handled manually.
@ -35,7 +45,7 @@ describe('A Solid server with IDP', (): void => {
let factory: HttpServerFactory; let factory: HttpServerFactory;
const redirectUrl = 'http://mockedredirect/'; const redirectUrl = 'http://mockedredirect/';
const oidcIssuer = baseUrl; const oidcIssuer = baseUrl;
const card = new URL('profile/card', baseUrl).href; const card = urljoin(baseUrl, 'profile/card');
const webId = `${card}#me`; const webId = `${card}#me`;
const email = 'test@test.com'; const email = 'test@test.com';
const password = 'password!'; const password = 'password!';
@ -52,9 +62,7 @@ describe('A Solid server with IDP', (): void => {
'urn:solid-server:test:Instances', 'urn:solid-server:test:Instances',
getTestConfigPath('server-memory.json'), getTestConfigPath('server-memory.json'),
{ {
'urn:solid-server:default:variable:port': port,
'urn:solid-server:default:variable:baseUrl': baseUrl, 'urn:solid-server:default:variable:baseUrl': baseUrl,
'urn:solid-server:default:variable:podTemplateFolder': joinFilePath(__dirname, '../assets/templates'),
'urn:solid-server:default:variable:idpTemplateFolder': joinFilePath(__dirname, '../../templates/idp'), 'urn:solid-server:default:variable:idpTemplateFolder': joinFilePath(__dirname, '../../templates/idp'),
}, },
) as Record<string, any>; ) as Record<string, any>;
@ -79,27 +87,16 @@ describe('A Solid server with IDP', (): void => {
}); });
describe('doing registration', (): void => { describe('doing registration', (): void => {
let state: IdentityTestState;
let nextUrl: string;
let formBody: string; let formBody: string;
let registrationTriple: string; let registrationTriple: string;
beforeAll(async(): Promise<void> => { beforeAll(async(): Promise<void> => {
state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer);
// We will need this twice // We will need this twice
formBody = stringify({ email, webId, password, confirmPassword: password, remember: 'yes' }); formBody = stringify({ email, webId, password, confirmPassword: password, register: 'ok' });
});
it('initializes the session and finds the registration URL.', async(): Promise<void> => {
const url = await state.startSession();
const { register } = await state.parseLoginPage(url);
expect(typeof register).toBe('string');
nextUrl = (await state.extractFormUrl(register)).url;
}); });
it('sends the form once to receive the registration triple.', async(): Promise<void> => { it('sends the form once to receive the registration triple.', async(): Promise<void> => {
const res = await state.fetchIdp(nextUrl, 'POST', formBody, APPLICATION_X_WWW_FORM_URLENCODED); const res = await postForm(`${baseUrl}idp/register`, formBody);
expect(res.status).toBe(200); expect(res.status).toBe(200);
// eslint-disable-next-line newline-per-chained-call // eslint-disable-next-line newline-per-chained-call
registrationTriple = load(await res.text())('form div label').first().text().trim().split('\n')[0]; registrationTriple = load(await res.text())('form div label').first().text().trim().split('\n')[0];
@ -119,15 +116,14 @@ describe('A Solid server with IDP', (): void => {
expect(res.status).toBe(205); expect(res.status).toBe(205);
}); });
it('sends the form again once the registration token was added.', async(): Promise<void> => { it('sends the form again to successfully register.', async(): Promise<void> => {
const res = await state.fetchIdp(nextUrl, 'POST', formBody, APPLICATION_X_WWW_FORM_URLENCODED); const res = await postForm(`${baseUrl}idp/register`, formBody);
expect(res.status).toBe(302); expect(res.status).toBe(200);
nextUrl = res.headers.get('location')!; const text = await res.text();
}); expect(text).toMatch(new RegExp(`You can now identify as .*${webId}.*with our IDP using ${email}`, 'u'));
expect(text).toMatch(new RegExp(`Make sure you add the triple
it('will be redirected internally and logged in.', async(): Promise<void> => { \\s*&lt;${webId}&gt; &lt;http://www.w3.org/ns/solid/terms#oidcIssuer&gt; &lt;${baseUrl}&gt;\\.
await state.handleLoginRedirect(nextUrl); \\s*to your WebID profile\\.`, 'mu'));
expect(state.session.info?.webId).toBe(webId);
}); });
}); });
@ -193,22 +189,10 @@ describe('A Solid server with IDP', (): void => {
}); });
describe('resetting password', (): void => { describe('resetting password', (): void => {
let state: IdentityTestState;
let nextUrl: string; let nextUrl: string;
beforeAll(async(): Promise<void> => {
state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer);
});
it('initializes the session and finds the forgot password URL.', async(): Promise<void> => {
const url = await state.startSession();
const { forgotPassword } = await state.parseLoginPage(url);
expect(typeof forgotPassword).toBe('string');
nextUrl = (await state.extractFormUrl(forgotPassword)).url;
});
it('sends the corresponding email address through the form to get a mail.', async(): Promise<void> => { it('sends the corresponding email address through the form to get a mail.', async(): Promise<void> => {
const res = await state.fetchIdp(nextUrl, 'POST', stringify({ email }), APPLICATION_X_WWW_FORM_URLENCODED); const res = await postForm(`${baseUrl}idp/forgotpassword`, stringify({ email }));
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(load(await res.text())('form div p').first().text().trim()) expect(load(await res.text())('form div p').first().text().trim())
.toBe('If your account exists, an email has been sent with a link to reset your password.'); .toBe('If your account exists, an email has been sent with a link to reset your password.');
@ -222,13 +206,23 @@ describe('A Solid server with IDP', (): void => {
}); });
it('resets the password through the given link.', async(): Promise<void> => { it('resets the password through the given link.', async(): Promise<void> => {
const { url, body } = await state.extractFormUrl(nextUrl); // Extract the submit URL from the reset password form
let res = await fetch(nextUrl);
expect(res.status).toBe(200);
const text = await res.text();
const relative = load(text)('form').attr('action');
expect(typeof relative).toBe('string');
const recordId = load(body)('input[name="recordId"]').attr('value'); const recordId = load(text)('input[name="recordId"]').attr('value');
expect(typeof recordId).toBe('string'); expect(typeof recordId).toBe('string');
// POST the new password
const formData = stringify({ password: password2, confirmPassword: password2, recordId }); const formData = stringify({ password: password2, confirmPassword: password2, recordId });
const res = await state.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED); res = await fetch(new URL(relative!, baseUrl).href, {
method: 'POST',
headers: { 'content-type': APPLICATION_X_WWW_FORM_URLENCODED },
body: formData,
});
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(await res.text()).toContain('Your password was successfully reset.'); expect(await res.text()).toContain('Your password was successfully reset.');
}); });
@ -261,4 +255,97 @@ describe('A Solid server with IDP', (): void => {
expect(state.session.info?.webId).toBe(webId); expect(state.session.info?.webId).toBe(webId);
}); });
}); });
describe('creating pods without registering', (): void => {
let formBody: string;
let registrationTriple: string;
const podName = 'myPod';
beforeAll(async(): Promise<void> => {
// We will need this twice
formBody = stringify({ email, webId, podName, createPod: 'ok' });
});
it('sends the form once to receive the registration triple.', async(): Promise<void> => {
const res = await postForm(`${baseUrl}idp/register`, formBody);
expect(res.status).toBe(200);
// eslint-disable-next-line newline-per-chained-call
registrationTriple = load(await res.text())('form div label').first().text().trim().split('\n')[0];
expect(registrationTriple).toMatch(new RegExp(
`^<${webId}> <http://www.w3.org/ns/solid/terms#oidcIssuerRegistrationToken> "[^"]+"\\s*\\.\\s*$`,
'u',
));
});
it('updates the webId with the registration token.', async(): Promise<void> => {
const patchBody = `INSERT DATA { ${registrationTriple} }`;
const res = await fetch(webId, {
method: 'PATCH',
headers: { 'content-type': 'application/sparql-update' },
body: patchBody,
});
expect(res.status).toBe(205);
});
it('sends the form again to successfully register.', async(): Promise<void> => {
const res = await postForm(`${baseUrl}idp/register`, formBody);
expect(res.status).toBe(200);
const text = await res.text();
expect(text).toMatch(new RegExp(`Your new pod has been created and can be found at.*${baseUrl}${podName}/`, 'u'));
});
});
describe('creating a new WebID', (): void => {
const podName = 'alice';
const newMail = 'alice@test.email';
let newWebId: string;
let podLocation: string;
let state: IdentityTestState;
const formBody = stringify({
email: newMail, password, confirmPassword: password, podName, createWebId: 'ok', register: 'ok', createPod: 'ok',
});
it('sends the form to create the WebID and register.', async(): Promise<void> => {
const res = await postForm(`${baseUrl}idp/register`, formBody);
expect(res.status).toBe(200);
const text = await res.text();
const matchWebId = /Your new WebID is [^>]+>([^<]+)/u.exec(text);
expect(matchWebId).toBeDefined();
expect(matchWebId).toHaveLength(2);
newWebId = matchWebId![1];
expect(text).toMatch(new RegExp(`You can now identify as .*${newWebId}.*with our IDP using ${newMail}`, 'u'));
const matchPod = /Your new pod has been created and can be found at [^>]+>([^<]+)/u.exec(text);
expect(matchPod).toBeDefined();
expect(matchPod).toHaveLength(2);
podLocation = matchPod![1];
expect(newWebId.startsWith(podLocation)).toBe(true);
expect(podLocation.startsWith(baseUrl)).toBe(true);
});
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);
expect(state.session.info?.webId).toBe(newWebId);
});
it('can only write to the new profile when using the logged in session.', async(): Promise<void> => {
const patchOptions = {
method: 'PATCH',
headers: { 'content-type': 'application/sparql-update' },
body: `INSERT DATA { <> <http://www.w3.org/2000/01/rdf-schema#label> "A cool WebID." }`,
};
let res = await fetch(newWebId, patchOptions);
expect(res.status).toBe(401);
res = await state.session.fetch(newWebId, patchOptions);
expect(res.status).toBe(205);
});
});
}); });

View File

@ -1,7 +1,10 @@
import type { Server } from 'http'; import type { Server } from 'http';
import { stringify } from 'querystring';
import fetch from 'cross-fetch'; import fetch from 'cross-fetch';
import type { Initializer } from '../../src/init/Initializer'; import type { Initializer } from '../../src/init/Initializer';
import type { HttpServerFactory } from '../../src/server/HttpServerFactory'; import type { HttpServerFactory } from '../../src/server/HttpServerFactory';
import type { WrappedExpiringStorage } from '../../src/storage/keyvalue/WrappedExpiringStorage';
import { joinFilePath } from '../../src/util/PathUtil';
import { getPort } from '../util/Util'; import { getPort } from '../util/Util';
import { getPresetConfigPath, getTestConfigPath, getTestFolder, instantiateFromConfig, removeFolder } from './Config'; import { getPresetConfigPath, getTestConfigPath, getTestFolder, instantiateFromConfig, removeFolder } from './Config';
@ -25,15 +28,16 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo
let server: Server; let server: Server;
let initializer: Initializer; let initializer: Initializer;
let factory: HttpServerFactory; let factory: HttpServerFactory;
const settings = { login: 'alice', webId: 'http://test.com/#alice', name: 'Alice Bob' }; let expiringStorage: WrappedExpiringStorage<any, any>;
const settings = { podName: 'alice', webId: 'http://test.com/#alice', email: 'alice@test.email', createPod: true };
const podHost = `alice.localhost:${port}`; const podHost = `alice.localhost:${port}`;
const podUrl = `http://${podHost}/`; const podUrl = `http://${podHost}/`;
beforeAll(async(): Promise<void> => { beforeAll(async(): Promise<void> => {
const variables: Record<string, any> = { const variables: Record<string, any> = {
'urn:solid-server:default:variable:port': port,
'urn:solid-server:default:variable:baseUrl': baseUrl, 'urn:solid-server:default:variable:baseUrl': baseUrl,
'urn:solid-server:default:variable:rootFilePath': rootFilePath, 'urn:solid-server:default:variable:rootFilePath': rootFilePath,
'urn:solid-server:default:variable:idpTemplateFolder': joinFilePath(__dirname, '../../templates/idp'),
}; };
// Create and initialize the server // Create and initialize the server
@ -45,13 +49,14 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo
], ],
variables, variables,
) as Record<string, any>; ) as Record<string, any>;
({ factory, initializer } = instances); ({ factory, initializer, expiringStorage } = instances);
await initializer.handleSafe(); await initializer.handleSafe();
server = factory.startServer(port); server = factory.startServer(port);
}); });
afterAll(async(): Promise<void> => { afterAll(async(): Promise<void> => {
expiringStorage.finalize();
await new Promise((resolve, reject): void => { await new Promise((resolve, reject): void => {
server.close((error): void => error ? reject(error) : resolve()); server.close((error): void => error ? reject(error) : resolve());
}); });
@ -83,15 +88,13 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo
describe('handling pods', (): void => { describe('handling pods', (): void => {
it('creates pods in a subdomain.', async(): Promise<void> => { it('creates pods in a subdomain.', async(): Promise<void> => {
const res = await fetch(`${baseUrl}pods`, { const res = await fetch(`${baseUrl}idp/register`, {
method: 'POST', method: 'POST',
headers: { headers: { 'content-type': 'application/x-www-form-urlencoded' },
'content-type': 'application/json', body: stringify(settings),
},
body: JSON.stringify(settings),
}); });
expect(res.status).toBe(201); expect(res.status).toBe(200);
expect(res.headers.get('location')).toBe(podUrl); await expect(res.text()).resolves.toContain(podUrl);
}); });
it('can fetch the created pod in a subdomain.', async(): Promise<void> => { it('can fetch the created pod in a subdomain.', async(): Promise<void> => {
@ -145,14 +148,14 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo
}); });
it('should not be able to create a pod with the same name.', async(): Promise<void> => { it('should not be able to create a pod with the same name.', async(): Promise<void> => {
const res = await fetch(`${baseUrl}pods`, { const res = await fetch(`${baseUrl}idp/register`, {
method: 'POST', method: 'POST',
headers: { headers: { 'content-type': 'application/x-www-form-urlencoded' },
'content-type': 'application/json', body: stringify(settings),
},
body: JSON.stringify(settings),
}); });
expect(res.status).toBe(409); // 200 due to there only being a HTML solution right now that only returns 200
expect(res.status).toBe(200);
await expect(res.text()).resolves.toContain(`There already is a resource at ${podUrl}`);
}); });
}); });
}); });

View File

@ -5,6 +5,9 @@
"files-scs:config/http/middleware/no-websockets.json", "files-scs:config/http/middleware/no-websockets.json",
"files-scs:config/http/server-factory/no-websockets.json", "files-scs:config/http/server-factory/no-websockets.json",
"files-scs:config/http/static/default.json", "files-scs:config/http/static/default.json",
"files-scs:config/identity/email/default.json",
"files-scs:config/identity/handler/default.json",
"files-scs:config/identity/ownership/unsafe-no-check.json",
"files-scs:config/init/handler/default.json", "files-scs:config/init/handler/default.json",
"files-scs:config/ldp/authentication/debug-auth-header.json", "files-scs:config/ldp/authentication/debug-auth-header.json",
"files-scs:config/ldp/authorization/webacl.json", "files-scs:config/ldp/authorization/webacl.json",
@ -37,12 +40,13 @@
{ {
"RecordObject:_record_key": "factory", "RecordObject:_record_key": "factory",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ServerFactory" } "RecordObject:_record_value": { "@id": "urn:solid-server:default:ServerFactory" }
}
]
}, },
{ {
"@id": "urn:solid-server:default:IdentityProviderHandler", "comment": "Timer needs to be stopped when tests are finished.",
"@type": "UnsupportedAsyncHandler" "RecordObject:_record_key": "expiringStorage",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ExpiringIdpStorage" }
}
]
}, },
{ {
"@id": "urn:solid-server:default:ResourcesGenerator", "@id": "urn:solid-server:default:ResourcesGenerator",

View File

@ -54,10 +54,6 @@
"args_emailConfig_port": 587, "args_emailConfig_port": 587,
"args_emailConfig_auth_user": "alice@example.email", "args_emailConfig_auth_user": "alice@example.email",
"args_emailConfig_auth_pass": "NYEaCsqV7aVStRCbmC" "args_emailConfig_auth_pass": "NYEaCsqV7aVStRCbmC"
},
{
"@id": "urn:solid-server:default:ResourcesGenerator",
"TemplatedResourcesGenerator:_templateFolder": "$PACKAGE_ROOT/test/assets/templates"
} }
] ]
} }

View File

@ -5,6 +5,9 @@
"files-scs:config/http/middleware/no-websockets.json", "files-scs:config/http/middleware/no-websockets.json",
"files-scs:config/http/server-factory/no-websockets.json", "files-scs:config/http/server-factory/no-websockets.json",
"files-scs:config/http/static/default.json", "files-scs:config/http/static/default.json",
"files-scs:config/identity/email/default.json",
"files-scs:config/identity/handler/default.json",
"files-scs:config/identity/ownership/unsafe-no-check.json",
"files-scs:config/init/handler/default.json", "files-scs:config/init/handler/default.json",
"files-scs:config/ldp/authentication/debug-auth-header.json", "files-scs:config/ldp/authentication/debug-auth-header.json",
"files-scs:config/ldp/authorization/webacl.json", "files-scs:config/ldp/authorization/webacl.json",
@ -33,20 +36,16 @@
"RecordObject:_record_key": "initializer", "RecordObject:_record_key": "initializer",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ParallelInitializer" } "RecordObject:_record_value": { "@id": "urn:solid-server:default:ParallelInitializer" }
}, },
{
"RecordObject:_record_key": "store",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ResourceStore" }
},
{ {
"RecordObject:_record_key": "factory", "RecordObject:_record_key": "factory",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ServerFactory" } "RecordObject:_record_value": { "@id": "urn:solid-server:default:ServerFactory" }
}
]
}, },
{ {
"@id": "urn:solid-server:default:IdentityProviderHandler", "comment": "Timer needs to be stopped when tests are finished.",
"@type": "UnsupportedAsyncHandler" "RecordObject:_record_key": "expiringStorage",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ExpiringIdpStorage" }
}
]
} }
] ]
} }

View File

@ -1,92 +1,260 @@
import type { Provider } from 'oidc-provider'; import urljoin from 'url-join';
import { import {
RegistrationHandler, RegistrationHandler,
} from '../../../../../../src/identity/interaction/email-password/handler/RegistrationHandler'; } from '../../../../../../src/identity/interaction/email-password/handler/RegistrationHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { InteractionCompleter } from '../../../../../../src/identity/interaction/util/InteractionCompleter'; import { IdpInteractionError } from '../../../../../../src/identity/interaction/util/IdpInteractionError';
import type { OwnershipValidator } from '../../../../../../src/identity/interaction/util/OwnershipValidator'; import type { OwnershipValidator } from '../../../../../../src/identity/ownership/OwnershipValidator';
import type { ResourceIdentifier } from '../../../../../../src/ldp/representation/ResourceIdentifier';
import type { IdentifierGenerator } from '../../../../../../src/pods/generate/IdentifierGenerator';
import type { PodManager } from '../../../../../../src/pods/PodManager';
import type { HttpRequest } from '../../../../../../src/server/HttpRequest'; import type { HttpRequest } from '../../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../../src/server/HttpResponse'; import type { HttpResponse } from '../../../../../../src/server/HttpResponse';
import type { RenderHandler } from '../../../../../../src/server/util/RenderHandler';
import { createPostFormRequest } from './Util'; import { createPostFormRequest } from './Util';
describe('A RegistrationHandler', (): void => { describe('A RegistrationHandler', (): void => {
// "Correct" values for easy object creation
const webId = 'http://alice.test.com/card#me'; const webId = 'http://alice.test.com/card#me';
const email = 'alice@test.email'; const email = 'alice@test.email';
const password = 'superSecretPassword';
const confirmPassword = password;
const podName = 'alice';
// Strings instead of booleans because this is form data
const createWebId = 'true';
const register = 'true';
const createPod = 'true';
let request: HttpRequest; let request: HttpRequest;
const response: HttpResponse = {} as any; const response: HttpResponse = {} as any;
const provider: Provider = {} as any;
const baseUrl = 'http://test.com/';
const webIdSuffix = '/profile/card';
let identifierGenerator: IdentifierGenerator;
let ownershipValidator: OwnershipValidator; let ownershipValidator: OwnershipValidator;
let accountStore: AccountStore; let accountStore: AccountStore;
let interactionCompleter: InteractionCompleter; let podManager: PodManager;
let responseHandler: RenderHandler<NodeJS.Dict<any>>;
let handler: RegistrationHandler; let handler: RegistrationHandler;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
identifierGenerator = {
generate: jest.fn((name: string): ResourceIdentifier => ({ path: `${baseUrl}${name}/` })),
};
ownershipValidator = { ownershipValidator = {
handleSafe: jest.fn(), handleSafe: jest.fn(),
} as any; } as any;
accountStore = { accountStore = {
create: jest.fn(), create: jest.fn(),
verify: jest.fn(),
deleteAccount: jest.fn(),
} as any; } as any;
interactionCompleter = { podManager = {
createPod: jest.fn(),
};
responseHandler = {
handleSafe: jest.fn(), handleSafe: jest.fn(),
} as any; } as any;
handler = new RegistrationHandler({ handler = new RegistrationHandler({
ownershipValidator, baseUrl,
webIdSuffix,
identifierGenerator,
accountStore, accountStore,
interactionCompleter, ownershipValidator,
podManager,
responseHandler,
}); });
}); });
it('errors on non-string emails.', async(): Promise<void> => { describe('validating data', (): void => {
request = createPostFormRequest({}); it('rejects array inputs.', async(): Promise<void> => {
await expect(handler.handle({ request, response, provider })).rejects.toThrow('Email required'); request = createPostFormRequest({ data: [ 'a', 'b' ]});
request = createPostFormRequest({ email: [ 'email', 'email2' ]}); await expect(handler.handle({ request, response })).rejects.toThrow('Multiple values found for key data');
await expect(handler.handle({ request, response, provider })).rejects.toThrow('Email required');
}); });
it('errors on invalid emails.', async(): Promise<void> => { it('errors on invalid emails.', async(): Promise<void> => {
request = createPostFormRequest({ email: undefined });
await expect(handler.handle({ request, response })).rejects.toThrow('A valid e-mail address is required');
request = createPostFormRequest({ email: '' });
await expect(handler.handle({ request, response })).rejects.toThrow('A valid e-mail address is required');
request = createPostFormRequest({ email: 'invalidEmail' }); request = createPostFormRequest({ email: 'invalidEmail' });
const prom = handler.handle({ request, response, provider }); await expect(handler.handle({ request, response })).rejects.toThrow('A valid e-mail address is required');
await expect(prom).rejects.toThrow('Invalid email');
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { }}));
}); });
it('errors on non-string webIds.', async(): Promise<void> => { it('errors when an unnecessary WebID is provided.', async(): Promise<void> => {
request = createPostFormRequest({ email }); request = createPostFormRequest({ email, webId, createWebId });
let prom = handler.handle({ request, response, provider }); await expect(handler.handle({ request, response }))
await expect(prom).rejects.toThrow('WebId required'); .rejects.toThrow('A WebID should only be provided when no new one is being created');
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }}));
request = createPostFormRequest({ email, webId: [ 'a', 'b' ]});
prom = handler.handle({ request, response, provider });
await expect(prom).rejects.toThrow('WebId required');
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }}));
}); });
it('errors on invalid passwords.', async(): Promise<void> => { it('errors when a required WebID is not valid.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, password: 'password!', confirmPassword: 'bad' }); request = createPostFormRequest({ email, webId: undefined });
const prom = handler.handle({ request, response, provider }); await expect(handler.handle({ request, response }))
await expect(prom).rejects.toThrow('Password and confirmation do not match'); .rejects.toThrow('A WebID is required if no new one is being created');
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email, webId }}));
request = createPostFormRequest({ email, webId: '' });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A WebID is required if no new one is being created');
}); });
it('throws an IdpInteractionError if there is a problem.', async(): Promise<void> => { it('errors when an unnecessary password is provided.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, password: 'password!', confirmPassword: 'password!' }); request = createPostFormRequest({ email, webId, password });
(accountStore.create as jest.Mock).mockRejectedValueOnce(new Error('create failed!')); await expect(handler.handle({ request, response }))
const prom = handler.handle({ request, response, provider }); .rejects.toThrow('A password should only be provided when registering');
await expect(prom).rejects.toThrow('create failed!');
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email, webId }}));
}); });
it('calls the OidcInteractionCompleter when done.', async(): Promise<void> => { it('errors on invalid passwords when registering.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, password: 'password!', confirmPassword: 'password!' }); request = createPostFormRequest({ email, webId, password, confirmPassword: 'bad', register });
await expect(handler.handle({ request, response, provider })).resolves.toBeUndefined(); await expect(handler.handle({ request, response })).rejects.toThrow('Password and confirmation do not match');
});
it('errors when an unnecessary pod name is provided.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, podName });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A pod name should only be provided when creating a pod and/or WebID');
});
it('errors on invalid pod names when required.', async(): Promise<void> => {
request = createPostFormRequest({ email, podName: undefined, createWebId });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A pod name is required when creating a pod and/or WebID');
request = createPostFormRequest({ email, webId, podName: '', createPod });
await expect(handler.handle({ request, response }))
.rejects.toThrow('A pod name is required when creating a pod and/or WebID');
});
it('errors when trying to create a WebID without registering or creating a pod.', async(): Promise<void> => {
request = createPostFormRequest({ email, podName, createWebId });
await expect(handler.handle({ request, response }))
.rejects.toThrow('Creating a WebID is only possible when also registering and creating a pod');
request = createPostFormRequest({ email, podName, password, confirmPassword, createWebId, register });
await expect(handler.handle({ request, response }))
.rejects.toThrow('Creating a WebID is only possible when also registering and creating a pod');
request = createPostFormRequest({ email, podName, createWebId, createPod });
await expect(handler.handle({ request, response }))
.rejects.toThrow('Creating a WebID is only possible when also registering and creating a pod');
});
it('errors when no option is chosen.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId });
await expect(handler.handle({ request, response })).rejects.toThrow('At least one option needs to be chosen');
});
});
describe('handling data', (): void => {
it('can register a user.', async(): Promise<void> => {
request = createPostFormRequest({ email, webId, password, confirmPassword, register });
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
expect(accountStore.create).toHaveBeenCalledTimes(1); expect(accountStore.create).toHaveBeenCalledTimes(1);
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, 'password!'); expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password);
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); expect(accountStore.verify).toHaveBeenCalledTimes(1);
expect(interactionCompleter.handleSafe) expect(accountStore.verify).toHaveBeenLastCalledWith(email);
.toHaveBeenLastCalledWith({ request, response, provider, webId, shouldRemember: false });
expect(identifierGenerator.generate).toHaveBeenCalledTimes(0);
expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0);
expect(podManager.createPod).toHaveBeenCalledTimes(0);
});
it('can create a pod.', async(): Promise<void> => {
const params = { email, webId, podName, createPod };
request = createPostFormRequest(params);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
expect(podManager.createPod).toHaveBeenCalledTimes(1);
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, params);
expect(accountStore.create).toHaveBeenCalledTimes(0);
expect(accountStore.verify).toHaveBeenCalledTimes(0);
expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0);
});
it('adds an oidcIssuer to the data when doing both IDP registration and pod creation.', async(): Promise<void> => {
const params = { email, webId, password, confirmPassword, podName, register, createPod };
request = createPostFormRequest(params);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
expect(accountStore.create).toHaveBeenCalledTimes(1);
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password);
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
(params as any).oidcIssuer = baseUrl;
expect(podManager.createPod).toHaveBeenCalledTimes(1);
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, params);
expect(accountStore.verify).toHaveBeenCalledTimes(1);
expect(accountStore.verify).toHaveBeenLastCalledWith(email);
expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0);
});
it('deletes the created account if pod generation fails.', async(): Promise<void> => {
const params = { email, webId, password, confirmPassword, podName, register, createPod };
request = createPostFormRequest(params);
(podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error'));
await expect(handler.handle({ request, response })).rejects.toThrow('pod error');
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
expect(accountStore.create).toHaveBeenCalledTimes(1);
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password);
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
(params as any).oidcIssuer = baseUrl;
expect(podManager.createPod).toHaveBeenCalledTimes(1);
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, params);
expect(accountStore.deleteAccount).toHaveBeenCalledTimes(1);
expect(accountStore.deleteAccount).toHaveBeenLastCalledWith(email);
expect(accountStore.verify).toHaveBeenCalledTimes(0);
});
it('can create a WebID with an account and pod.', async(): Promise<void> => {
const params = { email, password, confirmPassword, podName, createWebId, register, createPod };
request = createPostFormRequest(params);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
const generatedWebID = urljoin(baseUrl, podName, webIdSuffix);
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
expect(accountStore.create).toHaveBeenCalledTimes(1);
expect(accountStore.create).toHaveBeenLastCalledWith(email, generatedWebID, password);
expect(accountStore.verify).toHaveBeenCalledTimes(1);
expect(accountStore.verify).toHaveBeenLastCalledWith(email);
const podParams = { ...params, oidcIssuer: baseUrl, webId: generatedWebID };
expect(podManager.createPod).toHaveBeenCalledTimes(1);
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podParams);
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(0);
expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0);
});
it('throws an IdpInteractionError with all data prefilled if something goes wrong.', async(): Promise<void> => {
const params = { email, webId, podName, createPod };
request = createPostFormRequest(params);
(podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error'));
const prom = handler.handle({ request, response });
await expect(prom).rejects.toThrow('pod error');
await expect(prom).rejects.toThrow(IdpInteractionError);
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: params }));
});
}); });
}); });

View File

@ -1,6 +1,5 @@
import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier';
import { ConfigPodManager } from '../../../src/pods/ConfigPodManager'; import { ConfigPodManager } from '../../../src/pods/ConfigPodManager';
import type { IdentifierGenerator } from '../../../src/pods/generate/IdentifierGenerator';
import type { PodGenerator } from '../../../src/pods/generate/PodGenerator'; import type { PodGenerator } from '../../../src/pods/generate/PodGenerator';
import type { Resource, ResourcesGenerator } from '../../../src/pods/generate/ResourcesGenerator'; import type { Resource, ResourcesGenerator } from '../../../src/pods/generate/ResourcesGenerator';
import type { PodSettings } from '../../../src/pods/settings/PodSettings'; import type { PodSettings } from '../../../src/pods/settings/PodSettings';
@ -10,9 +9,6 @@ import type { ResourceStore } from '../../../src/storage/ResourceStore';
describe('A ConfigPodManager', (): void => { describe('A ConfigPodManager', (): void => {
let settings: PodSettings; let settings: PodSettings;
const base = 'http://test.com/'; const base = 'http://test.com/';
const idGenerator: IdentifierGenerator = {
generate: (slug: string): ResourceIdentifier => ({ path: `${base}${slug}/` }),
};
let store: ResourceStore; let store: ResourceStore;
let podGenerator: PodGenerator; let podGenerator: PodGenerator;
let routingStorage: KeyValueStorage<string, ResourceStore>; let routingStorage: KeyValueStorage<string, ResourceStore>;
@ -50,12 +46,12 @@ describe('A ConfigPodManager', (): void => {
set: async(key: ResourceIdentifier, value: ResourceStore): Promise<any> => map.set(key, value), set: async(key: ResourceIdentifier, value: ResourceStore): Promise<any> => map.set(key, value),
} as any; } as any;
manager = new ConfigPodManager(idGenerator, podGenerator, resourcesGenerator, routingStorage); manager = new ConfigPodManager(podGenerator, resourcesGenerator, routingStorage);
}); });
it('creates a pod and returns the newly generated identifier.', async(): Promise<void> => { it('creates a pod and returns the newly generated identifier.', async(): Promise<void> => {
const identifier = { path: `${base}alice/` }; const identifier = { path: `${base}alice/` };
await expect(manager.createPod(settings)).resolves.toEqual(identifier); await expect(manager.createPod(identifier, settings)).resolves.toBeUndefined();
expect(podGenerator.generate).toHaveBeenCalledTimes(1); expect(podGenerator.generate).toHaveBeenCalledTimes(1);
expect(podGenerator.generate).toHaveBeenLastCalledWith(identifier, settings); expect(podGenerator.generate).toHaveBeenLastCalledWith(identifier, settings);
expect(resourcesGenerator.generate).toHaveBeenCalledTimes(1); expect(resourcesGenerator.generate).toHaveBeenCalledTimes(1);

View File

@ -1,5 +1,3 @@
import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier';
import type { IdentifierGenerator } from '../../../src/pods/generate/IdentifierGenerator';
import type { Resource, ResourcesGenerator } from '../../../src/pods/generate/ResourcesGenerator'; import type { Resource, ResourcesGenerator } from '../../../src/pods/generate/ResourcesGenerator';
import { GeneratedPodManager } from '../../../src/pods/GeneratedPodManager'; import { GeneratedPodManager } from '../../../src/pods/GeneratedPodManager';
import type { PodSettings } from '../../../src/pods/settings/PodSettings'; import type { PodSettings } from '../../../src/pods/settings/PodSettings';
@ -11,9 +9,6 @@ describe('A GeneratedPodManager', (): void => {
let settings: PodSettings; let settings: PodSettings;
let store: jest.Mocked<ResourceStore>; let store: jest.Mocked<ResourceStore>;
let generatorData: Resource[]; let generatorData: Resource[];
const idGenerator: IdentifierGenerator = {
generate: (slug: string): ResourceIdentifier => ({ path: `${base}${slug}/` }),
};
let resGenerator: ResourcesGenerator; let resGenerator: ResourcesGenerator;
let manager: GeneratedPodManager; let manager: GeneratedPodManager;
@ -37,18 +32,18 @@ describe('A GeneratedPodManager', (): void => {
yield* generatorData; yield* generatorData;
}), }),
}; };
manager = new GeneratedPodManager(store, idGenerator, resGenerator); manager = new GeneratedPodManager(store, resGenerator);
}); });
it('throws an error if the generate identifier is not available.', async(): Promise<void> => { it('throws an error if the generate identifier is not available.', async(): Promise<void> => {
store.resourceExists.mockResolvedValueOnce(true); store.resourceExists.mockResolvedValueOnce(true);
const result = manager.createPod(settings); const result = manager.createPod({ path: `${base}user/` }, settings);
await expect(result).rejects.toThrow(`There already is a resource at ${base}user/`); await expect(result).rejects.toThrow(`There already is a resource at ${base}user/`);
await expect(result).rejects.toThrow(ConflictHttpError); await expect(result).rejects.toThrow(ConflictHttpError);
}); });
it('generates an identifier and writes containers before writing the resources in them.', async(): Promise<void> => { it('generates an identifier and writes containers before writing the resources in them.', async(): Promise<void> => {
await expect(manager.createPod(settings)).resolves.toEqual({ path: `${base}${settings.login}/` }); await expect(manager.createPod({ path: `${base}${settings.login}/` }, settings)).resolves.toBeUndefined();
expect(store.setRepresentation).toHaveBeenCalledTimes(3); expect(store.setRepresentation).toHaveBeenCalledTimes(3);
expect(store.setRepresentation).toHaveBeenNthCalledWith(1, { path: '/path/' }, '/'); expect(store.setRepresentation).toHaveBeenNthCalledWith(1, { path: '/path/' }, '/');