Skip to content

Commit

Permalink
Save email verification token to person's pod (#5)
Browse files Browse the repository at this point in the history
Before GET /verify-email, person must create a settings file
that this service can discover and write to.
This can be done automatically via app, e.g. sleepy.bike
  • Loading branch information
mrkvon committed Jan 17, 2024
1 parent 6576f66 commit 6a40af1
Show file tree
Hide file tree
Showing 9 changed files with 370 additions and 185 deletions.
3 changes: 3 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
49 changes: 46 additions & 3 deletions src/controllers/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: <http://www.w3.org/ns/solid/terms#>.
_: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
}
119 changes: 1 addition & 118 deletions src/controllers/status.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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()
}
}
}
13 changes: 13 additions & 0 deletions src/test/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 6a40af1

Please sign in to comment.