diff --git a/src/config/index.ts b/src/config/index.ts index db0f965..6a3bb0a 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -65,3 +65,6 @@ export const jwt = { export const emailDiscoveryType = process.env.EMAIL_DISCOVERY_TYPE ?? 'http://w3id.org/hospex/ns#PersonalHospexDocument' + +export const verificationTokenPredicate = + 'https://example.com/emailVerificationToken' diff --git a/src/controllers/integration.ts b/src/controllers/integration.ts index a141a81..9777e45 100644 --- a/src/controllers/integration.ts +++ b/src/controllers/integration.ts @@ -5,6 +5,7 @@ import { Middleware } from 'koa' import { pick } from 'lodash' import * as config from '../config' import { sendMail } from '../services/mailerService' +import { findWritableSettings, getBotFetch } from '../utils' export const initializeIntegration: Middleware = async ctx => { // we should receive info about webId and email address @@ -84,7 +85,49 @@ export const finishIntegration: Middleware = async ctx => { pem, { algorithm: 'ES256' }, ) - ctx.response.body = jwt - ctx.set('content-type', 'text/plain') - ctx.response.status = 200 + + // save the token to person + const savedTokensCount = await saveTokenToPerson(jwt, webId) + + if (savedTokensCount === 0) { + ctx.response.status = 400 + ctx.set('content-type', 'application/json') + ctx.response.body({ + error: + "We could't find any writeable location on your Pod to save the email verifiation token. You can write it manually.", + token: jwt, + }) + } else { + ctx.response.body = jwt + ctx.set('content-type', 'text/plain') + ctx.response.status = 200 + } +} + +/** + * Find writable settings files on person's pod + * and save the email verification token there + */ +const saveTokenToPerson = async (token: string, webId: string) => { + // find candidates for saving the token + const settings = await findWritableSettings(webId) + const authBotFetch = await getBotFetch() + // save the token to the candidates and keep track of how many succeeded + let okCount = 0 + for (const uri of settings) { + const patch = `@prefix solid: . + _:patch a solid:InsertDeletePatch; + solid:inserts { + <${webId}> <${config.verificationTokenPredicate}> "${token}" . + } .` + const response = await authBotFetch(uri, { + method: 'PATCH', + headers: { 'content-type': 'text/n3' }, + body: patch, + }) + + if (response.ok) okCount++ + } + + return okCount } diff --git a/src/controllers/status.ts b/src/controllers/status.ts index b93d779..f8285f4 100644 --- a/src/controllers/status.ts +++ b/src/controllers/status.ts @@ -1,14 +1,8 @@ -import { RdfQuery } from '@ldhop/core/dist/src' -import { QueryAndStore } from '@ldhop/core/dist/src/QueryAndStore' -import { fetchRdfDocument } from '@ldhop/core/dist/src/utils/helpers' -import { getAuthenticatedFetch as getAuthenticatedFetch6x } from 'css-authn/dist/6.x' -import { getAuthenticatedFetch as getAuthenticatedFetch7x } from 'css-authn/dist/7.x' import { readFile } from 'fs-extra' import { verify } from 'jsonwebtoken' import type { DefaultContext, DefaultState, Middleware } from 'koa' -import { NamedNode, Quad } from 'n3' -import { dct, rdfs, solid, space } from 'rdf-namespaces' import * as config from '../config' +import { findEmailVerificationTokens } from '../utils' export const getVerifiedEmails = async (webId: string) => { const tokens = await findEmailVerificationTokens(webId) @@ -46,114 +40,3 @@ export const getStatus: Middleware< ctx.response.body = { emailVerified: verifiedEmails.length > 0 } ctx.response.status = 200 } - -/** - * To find verified email of a person - * - * - Go to person's webId - * - Find public type index `(webId) - solid:publicTypeIndex -> (publicTypeIndex)`` - * - Find instances of specific class defined in config (EMAIL_DISCOVERY_TYPE defaults to hospex:PersonalHospexDocument) - * - Find settings in the relevant instance (webId) - space:preferencesFile -> (settings) - * - In the settings, find (webId) - example:emailVerificationToken -> (JWT) - */ -const findEmailQuery: RdfQuery = [ - // Go to person's webId and fetch extended profile documents, too - { - type: 'match', - subject: '?person', - predicate: rdfs.seeAlso, - pick: 'object', - target: '?extendedDocument', - }, - { type: 'add resources', variable: '?extendedDocument' }, - // Find public type index - { - type: 'match', - subject: '?person', - predicate: solid.publicTypeIndex, - pick: 'object', - target: '?publicTypeIndex', - }, - // Find instances of specific class defined in config (EMAIL_DISCOVERY_TYPE) - { - type: 'match', - subject: '?publicTypeIndex', - predicate: dct.references, - pick: 'object', - target: '?typeRegistration', - }, - { - type: 'match', - subject: '?typeRegistration', - predicate: solid.forClass, - object: config.emailDiscoveryType, - pick: 'subject', - target: '?typeRegistrationForClass', - }, - { - type: 'match', - subject: '?typeRegistrationForClass', - predicate: solid.instance, - pick: 'object', - target: `?classDocument`, - }, - { type: 'add resources', variable: '?classDocument' }, - // Find settings - { - type: 'match', - subject: '?person', - predicate: space.preferencesFile, - pick: 'object', - target: '?settings', - }, - { type: 'add resources', variable: '?settings' }, -] - -const findEmailVerificationTokens = async (webId: string) => { - // initialize knowledge graph and follow your nose through it - // according to the query - const qas = new QueryAndStore(findEmailQuery, { person: new Set([webId]) }) - await run(qas) - - // Find email verification tokens - const objects = qas.store.getObjects( - new NamedNode(webId), - new NamedNode('https://example.com/emailVerificationToken'), - null, - ) - - return objects.map(o => o.value) -} - -const fetchRdf = async (uri: string) => { - const getAuthenticatedFetch = - config.mailerCredentials.cssVersion === 6 - ? getAuthenticatedFetch6x - : getAuthenticatedFetch7x - const authBotFetch = await getAuthenticatedFetch(config.mailerCredentials) - - const { data: quads } = await fetchRdfDocument(uri, authBotFetch) - - return quads -} - -/** - * Follow your nose through the linked data graph by query - */ -const run = async (qas: QueryAndStore) => { - let missingResources = qas.getMissingResources() - - while (missingResources.length > 0) { - let quads: Quad[] = [] - const res = missingResources[0] - try { - quads = await fetchRdf(missingResources[0]) - } catch (e) { - // eslint-disable-next-line no-console - console.error(e) - } finally { - qas.addResource(res, quads) - missingResources = qas.getMissingResources() - } - } -} diff --git a/src/test/helpers/index.ts b/src/test/helpers/index.ts index c502caf..27034f5 100644 --- a/src/test/helpers/index.ts +++ b/src/test/helpers/index.ts @@ -5,6 +5,8 @@ import { createSandbox } from 'sinon' import { v4 as uuidv4 } from 'uuid' import * as config from '../../config' import * as mailerService from '../../services/mailerService' +import { setupEmailSettings } from './setupPod' +import { Person } from './types' export const createRandomAccount = ({ solidServer, @@ -54,15 +56,26 @@ const finishIntegration = async (verificationLink: string) => { export const verifyEmail = async ({ email, + person, authenticatedFetch, }: { email: string + person: Person authenticatedFetch: typeof fetch }) => { + await setupEmailSettings({ + person, + email: '', + emailVerificationToken: '', + authenticatedFetch, + skipSettings: true, + }) + const { verificationLink } = await initIntegration({ email, authenticatedFetch, }) + const { token } = await finishIntegration(verificationLink) return token diff --git a/src/test/helpers/setupPod.ts b/src/test/helpers/setupPod.ts index 838fadd..8bb63c1 100644 --- a/src/test/helpers/setupPod.ts +++ b/src/test/helpers/setupPod.ts @@ -11,7 +11,7 @@ const createFile = async ({ }: { url: string body: string - acl?: { read?: 'public' | string } + acl?: { read?: 'public' | string; write?: string; own: string } authenticatedFetch: typeof fetch }) => { const response = await authenticatedFetch(url, { @@ -22,10 +22,38 @@ const createFile = async ({ expect(response.ok).to.be.true - if (acl && acl.read) { - if (acl.read === 'public') - await addPublicRead({ resource: url, authenticatedFetch }) - else await addRead({ resource: url, agent: acl.read, authenticatedFetch }) + if (acl) { + await addAcl({ + permissions: ['Read', 'Write', 'Control'], + agents: [acl.own], + resource: url, + authenticatedFetch, + }) + + if (acl?.read) { + if (acl.read === 'public') + await addAcl({ + permissions: ['Read'], + agents: [], + isPublic: true, + resource: url, + authenticatedFetch, + }) + else + await addAcl({ + permissions: ['Read'], + agents: [acl.read], + resource: url, + authenticatedFetch, + }) + } + if (acl?.write) + await addAcl({ + permissions: ['Write'], + agents: [acl.write], + resource: url, + authenticatedFetch, + }) } } @@ -64,23 +92,31 @@ export const setupEmailSettings = async ({ email, emailVerificationToken, authenticatedFetch, + skipSettings = false, }: { person: Person email: string emailVerificationToken: string authenticatedFetch: typeof fetch + skipSettings?: boolean // this option is useful for testing email integration }) => { // add email settings, readable by mailer - const settings = ` + const settings = skipSettings + ? '' + : ` <${person.webId}> <${foaf.mbox}> "${email}"; - "${emailVerificationToken}".` + <${config.verificationTokenPredicate}> "${emailVerificationToken}".` const settingsPath = person.podUrl + 'hospex/email' await createFile({ url: settingsPath, body: settings, - acl: { read: config.mailerCredentials.webId }, + acl: { + read: config.mailerCredentials.webId, + write: config.mailerCredentials.webId, + own: person.webId, + }, authenticatedFetch, }) @@ -92,7 +128,7 @@ export const setupEmailSettings = async ({ await createFile({ url: hospexDocumentPath, body: hospexDocument, - acl: { read: config.mailerCredentials.webId }, + acl: { read: config.mailerCredentials.webId, own: person.webId }, authenticatedFetch, }) @@ -110,7 +146,7 @@ export const setupEmailSettings = async ({ await createFile({ url: publicTypeIndexPath, body: publicTypeIndex, - acl: { read: 'public' }, + acl: { read: 'public', own: person.webId }, authenticatedFetch, }) @@ -122,50 +158,31 @@ export const setupEmailSettings = async ({ inserts: profileDocumentPatch, authenticatedFetch, }) -} -/** - * Give agent a read access (e.g. to inbox) - * - * Currently assumes we're changing rights to container! - */ -const addRead = async ({ - resource, - agent, - authenticatedFetch, -}: { - resource: string - agent: string - authenticatedFetch: typeof fetch -}) => { - const response = await authenticatedFetch(resource + '.acl', { - method: 'PATCH', - headers: { 'content-type': 'text/n3' }, - body: ` - @prefix acl: . - - _:mutate a <${solid.InsertDeletePatch}>; <${solid.inserts}> { - <#Read> - a acl:Authorization; - acl:agent <${agent}>; - acl:accessTo <${resource}>; - acl:default <${resource}>; - acl:mode acl:Read. - }.`, - }) - - expect(response.ok).to.be.true - - return response + return { + settings: settingsPath, + hospexDocument: hospexDocumentPath, + publicTypeIndex: publicTypeIndexPath, + } } -const addPublicRead = async ({ +const addAcl = async ({ + permissions, + agents, + isPublic = false, resource, + isDefault = false, authenticatedFetch, }: { + permissions: ('Read' | 'Write' | 'Append' | 'Control')[] + agents: string[] + isPublic?: boolean resource: string + isDefault?: boolean authenticatedFetch: typeof fetch }) => { + if (permissions.length === 0) + throw new Error('You need to specify at least one permission') const response = await authenticatedFetch(resource + '.acl', { method: 'PATCH', headers: { 'content-type': 'text/n3' }, @@ -173,11 +190,13 @@ const addPublicRead = async ({ @prefix acl: . _:mutate a <${solid.InsertDeletePatch}>; <${solid.inserts}> { - <#Read> + <#${permissions.join('')}> a acl:Authorization; - acl:agentClass ; + ${agents.length > 0 ? `acl:agent <${agents.join(', ')}>;` : ''} + ${isPublic ? `acl:agentClass <${foaf.Agent}>;` : ''} acl:accessTo <${resource}>; - acl:mode acl:Read. + ${isDefault ? `acl:default <${resource}>;` : ''} + acl:mode ${permissions.map(p => `acl:${p}`).join(', ')}. }.`, }) diff --git a/src/test/integration-finish.spec.ts b/src/test/integration-finish.spec.ts index 4f3c89d..7725533 100644 --- a/src/test/integration-finish.spec.ts +++ b/src/test/integration-finish.spec.ts @@ -4,12 +4,15 @@ import * as jsonwebtoken from 'jsonwebtoken' import { describe } from 'mocha' import { SinonSandbox, createSandbox } from 'sinon' import * as config from '../config' +import { fetchRdf } from '../utils' import { initIntegration } from './helpers' +import { setupEmailSettings } from './helpers/setupPod' import { authenticatedFetch, person } from './testSetup.spec' describe('email verification via /verify-email?token=jwt', () => { let verificationLink: string let sandbox: SinonSandbox + let settings: string beforeEach(() => { sandbox = createSandbox() @@ -28,6 +31,16 @@ describe('email verification via /verify-email?token=jwt', () => { })) }) + beforeEach(async () => { + ;({ settings } = await setupEmailSettings({ + person, + email: '', + emailVerificationToken: '', + authenticatedFetch, + skipSettings: true, + })) + }) + it('[correct token] should respond with 200', async () => { const response = await fetch(verificationLink) expect(response.status).to.equal(200) @@ -48,7 +61,24 @@ describe('email verification via /verify-email?token=jwt', () => { }) }) - it("[correct token] (maybe) should save the verification proof to user's pod") + it("[correct token] should save the verification proof to user's pod", async () => { + // before, the settings should be empty + const quadsBefore = await fetchRdf(settings) + expect(quadsBefore).to.have.length(0) + + const response = await fetch(verificationLink) + expect(response.ok).to.be.true + const jwt = await response.text() + + // after, the settings should contain triple "token". + const quadsAfter = await fetchRdf(settings) + expect(quadsAfter).to.have.length(1) + expect(quadsAfter[0].subject.value).to.equal(person.webId) + expect(quadsAfter[0].predicate.value).to.equal( + config.verificationTokenPredicate, + ) + expect(quadsAfter[0].object.value).to.equal(jwt) + }) it('[incorrect token] should respond with 400', async () => { const response = await fetch( diff --git a/src/test/notification.spec.ts b/src/test/notification.spec.ts index 06ec11e..54a9e3d 100644 --- a/src/test/notification.spec.ts +++ b/src/test/notification.spec.ts @@ -7,7 +7,6 @@ import { baseUrl } from '../config' import type { GoodBody } from '../controllers/notification' import * as mailerService from '../services/mailerService' import { verifyEmail } from './helpers' -import { setupEmailSettings } from './helpers/setupPod' import { authenticatedFetch, authenticatedFetch3, @@ -19,6 +18,9 @@ import { const email = 'email@example.com' +/** + * Generate body for POST /notification + */ const getBody = ({ from, to, @@ -41,14 +43,9 @@ describe('send notification via /notification', () => { beforeEach(async () => { // setup email for receiver - const token = await verifyEmail({ + await verifyEmail({ email, - authenticatedFetch: authenticatedFetch3, - }) - await setupEmailSettings({ person: person3, - email, - emailVerificationToken: token, authenticatedFetch: authenticatedFetch3, }) }) diff --git a/src/test/status.spec.ts b/src/test/status.spec.ts index 6bca5a7..e5daeaf 100644 --- a/src/test/status.spec.ts +++ b/src/test/status.spec.ts @@ -3,7 +3,6 @@ import fetch from 'cross-fetch' import { describe } from 'mocha' import { baseUrl } from '../config' import { verifyEmail } from './helpers' -import { setupEmailSettings } from './helpers/setupPod' import { authenticatedFetch, authenticatedFetch3, @@ -17,13 +16,7 @@ const email = 'email@example.com' describe('get info about integrations of a person with GET /status/:webId', () => { beforeEach(async () => { - const token = await verifyEmail({ email, authenticatedFetch }) - await setupEmailSettings({ - person, - email, - emailVerificationToken: token, - authenticatedFetch, - }) + await verifyEmail({ email, authenticatedFetch, person }) }) it('[not authenticated] should fail with 401', async () => { diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..85db62b --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,204 @@ +import { RdfQuery } from '@ldhop/core/dist/src' +import { QueryAndStore } from '@ldhop/core/dist/src/QueryAndStore' +import { fetchRdfDocument } from '@ldhop/core/dist/src/utils/helpers' +import { getAuthenticatedFetch as getAuthenticatedFetch6x } from 'css-authn/dist/6.x' +import { getAuthenticatedFetch as getAuthenticatedFetch7x } from 'css-authn/dist/7.x' +import { NamedNode, Quad } from 'n3' +import { dct, rdfs, solid, space } from 'rdf-namespaces' +import * as config from './config' + +/** + * To find verified email of a person + * + * - Go to person's webId + * - Find public type index `(webId) - solid:publicTypeIndex -> (publicTypeIndex)`` + * - Find instances of specific class defined in config (EMAIL_DISCOVERY_TYPE defaults to hospex:PersonalHospexDocument) + * - Find settings in the relevant instance (webId) - space:preferencesFile -> (settings) + * - In the settings, find (webId) - example:emailVerificationToken -> (JWT) + */ +const findEmailQuery: RdfQuery = [ + // Go to person's webId and fetch extended profile documents, too + { + type: 'match', + subject: '?person', + predicate: rdfs.seeAlso, + pick: 'object', + target: '?extendedDocument', + }, + { type: 'add resources', variable: '?extendedDocument' }, + // Find public type index + { + type: 'match', + subject: '?person', + predicate: solid.publicTypeIndex, + pick: 'object', + target: '?publicTypeIndex', + }, + // Find instances of specific class defined in config (EMAIL_DISCOVERY_TYPE) + { + type: 'match', + subject: '?publicTypeIndex', + predicate: dct.references, + pick: 'object', + target: '?typeRegistration', + }, + { + type: 'match', + subject: '?typeRegistration', + predicate: solid.forClass, + object: config.emailDiscoveryType, + pick: 'subject', + target: '?typeRegistrationForClass', + }, + { + type: 'match', + subject: '?typeRegistrationForClass', + predicate: solid.instance, + pick: 'object', + target: `?classDocument`, + }, + { type: 'add resources', variable: '?classDocument' }, + // Find settings + { + type: 'match', + subject: '?person', + predicate: space.preferencesFile, + pick: 'object', + target: '?settings', + }, + { type: 'add resources', variable: '?settings' }, +] + +/** + * Search through person's storage and find email verification token in settings + */ +export const findEmailVerificationTokens = async (webId: string) => { + // initialize knowledge graph and follow your nose through it + // according to the query + const qas = new QueryAndStore(findEmailQuery, { person: new Set([webId]) }) + await run(qas) + + // Find email verification tokens + const objects = qas.store.getObjects( + new NamedNode(webId), + new NamedNode(config.verificationTokenPredicate), + null, + ) + + return objects.map(o => o.value) +} + +/** + * Given a webId of person, find settings that the bot identity can write to + */ +export const findWritableSettings = async (webId: string) => { + // initialize knowledge graph and follow your nose through it + // according to the query + const qas = new QueryAndStore(findEmailQuery, { person: new Set([webId]) }) + await run(qas) + + // get uris of settings + const settings = qas.getVariable('settings') + + // find out which settings the bot can edit + const authBotFetch = await getBotFetch() + const results = await Promise.allSettled( + settings.map(s => getAllowedAccess(s, authBotFetch)), + ) + + const writableSettings = settings.filter((setting, index) => { + const result = results[index] + return ( + result.status === 'fulfilled' && + (result.value.user.includes('write') || + result.value.user.includes('append')) + ) + }) + + return writableSettings +} + +type Permission = 'read' | 'write' | 'append' | 'control' +type PermissionDict = { + user: Permission[] + public: Permission[] +} + +/** + * Parse WAC-Allow header + * https://solid.github.io/web-access-control-spec/#wac-allow + * user="append read write",public="read" + */ +const parseWACAllowHeader = (header: string): PermissionDict => { + const result: PermissionDict = { user: [], public: [] } + + const entries = header.split(',') + + for (const entry of entries) { + const [key, value] = entry.split('=') as ['user' | 'public', string] + result[key] = value.trim().replace(/"/g, '').split(' ') as Permission[] + } + + return result +} + +/** + * Given url, find what kind of accesses current user has + * and what kind of accesses public has + * + * This is done via WAC-Allow header + * https://solid.github.io/web-access-control-spec/#wac-allow + */ +const getAllowedAccess = async ( + url: string, + authenticatedFetch: typeof fetch, +) => { + const response = await authenticatedFetch(url, { method: 'head' }) + const header = response.headers.get('wac-allow') + if (header === null) + throw new Error('WAC-Allow header not found for resource ' + url) + const permissions = parseWACAllowHeader(header) + + return permissions +} + +/** + * Get authenticated fetch for the notification bot identity defined in config + */ +export const getBotFetch = async () => { + const getAuthenticatedFetch = + config.mailerCredentials.cssVersion === 6 + ? getAuthenticatedFetch6x + : getAuthenticatedFetch7x + return await getAuthenticatedFetch(config.mailerCredentials) +} + +/** + * Fetch RDF document with notification bot identity, and return quads + */ +export const fetchRdf = async (uri: string) => { + const authBotFetch = await getBotFetch() + const { data: quads } = await fetchRdfDocument(uri, authBotFetch) + return quads +} + +/** + * Follow your nose through the linked data graph by query + */ +const run = async (qas: QueryAndStore) => { + let missingResources = qas.getMissingResources() + + while (missingResources.length > 0) { + let quads: Quad[] = [] + const res = missingResources[0] + try { + quads = await fetchRdf(missingResources[0]) + } catch (e) { + // eslint-disable-next-line no-console + console.error(e) + } finally { + qas.addResource(res, quads) + missingResources = qas.getMissingResources() + } + } +}