Skip to content

Commit

Permalink
feat: add support for access/authorize and update (#392)
Browse files Browse the repository at this point in the history
closes #386
  • Loading branch information
hugomrdias authored Feb 1, 2023
1 parent fc53a16 commit 9c8ca0b
Show file tree
Hide file tree
Showing 19 changed files with 1,178 additions and 634 deletions.
21 changes: 21 additions & 0 deletions packages/access-api/migrations/0004_add_accounts_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Migration number: 0004 2023-01-24T15:09:12.316Z
CREATE TABLE
IF NOT EXISTS accounts (
did TEXT NOT NULL PRIMARY KEY,
inserted_at TEXT NOT NULL DEFAULT (strftime ('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime ('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE (did)
);

CREATE TABLE
IF NOT EXISTS delegations (
cid TEXT NOT NULL PRIMARY KEY,
bytes BLOB NOT NULL,
audience TEXT NOT NULL,
issuer TEXT NOT NULL,
expiration TEXT,
inserted_at TEXT NOT NULL DEFAULT (strftime ('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime ('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE (cid),
FOREIGN KEY (audience) REFERENCES accounts (did) ON UPDATE CASCADE ON DELETE CASCADE
);
10 changes: 9 additions & 1 deletion packages/access-api/src/bindings.d.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import type { Logging } from '@web3-storage/worker-utils/logging'
import type { SpaceTable } from '@web3-storage/access/types'
import type {
AccountTable,
DelegationTable,
SpaceTable,
} from '@web3-storage/access/types'
import type { Handler as _Handler } from '@web3-storage/worker-utils/router'
import { Email } from './utils/email.js'
import { Spaces } from './models/spaces.js'
import { Validations } from './models/validations.js'
import { loadConfig } from './config.js'
import { Signer as EdSigner } from '@ucanto/principal/ed25519'
import { Accounts } from './models/accounts.js'

export {}

Expand Down Expand Up @@ -53,6 +58,7 @@ export interface RouteContext {
models: {
spaces: Spaces
validations: Validations
accounts: Accounts
}
uploadApi: {
production?: URL
Expand Down Expand Up @@ -98,4 +104,6 @@ export interface D1ErrorRaw extends Error {

export interface D1Schema {
spaces: SpaceTable
accounts: AccountTable
delegations: DelegationTable
}
112 changes: 112 additions & 0 deletions packages/access-api/src/models/accounts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// eslint-disable-next-line no-unused-vars
import * as Ucanto from '@ucanto/interface'
import {
delegationsToBytes,
expirationToDate,
} from '@web3-storage/access/encoding'
import { Kysely } from 'kysely'
import { D1Dialect } from 'kysely-d1'
import { GenericPlugin } from '../utils/d1.js'

/**
* @typedef {import('@web3-storage/access/src/types.js').DelegationRecord} DelegationRecord
*/

/**
* Accounts
*/
export class Accounts {
/**
*
* @param {D1Database} d1
*/
constructor(d1) {
/** @type {GenericPlugin<DelegationRecord>} */
const objectPlugin = new GenericPlugin({
// eslint-disable-next-line unicorn/no-null
expires_at: (v) => (typeof v === 'string' ? new Date(v) : null),
inserted_at: (v) => new Date(v),
updated_at: (v) => new Date(v),
})
this.d1 = /** @type {Kysely<import('../bindings').D1Schema>} */ (
new Kysely({
dialect: new D1Dialect({ database: d1 }),
plugins: [objectPlugin],
})
)
}

/**
* @param {Ucanto.URI<"did:">} did
*/
async create(did) {
const result = await this.d1
.insertInto('accounts')
.values({
did,
})
.onConflict((oc) => oc.column('did').doNothing())
.returning('accounts.did')
.execute()
return { data: result }
}

/**
*
* @param {Ucanto.Delegation} del
*/
async addDelegation(del) {
const result = await this.d1
.insertInto('delegations')
.values({
cid: del.cid.toV1().toString(),
audience: del.audience.did(),
issuer: del.issuer.did(),
bytes: delegationsToBytes([del]),
expires_at: expirationToDate(del.expiration),
})
.onConflict((oc) => oc.column('cid').doNothing())
.returningAll()
.executeTakeFirst()
return result
}

/**
* @param {Ucanto.URI<"did:">} did
*/
async get(did) {
return await this.d1
.selectFrom('accounts')
.selectAll()
.where('accounts.did', '=', did)
.executeTakeFirst()
}

/**
* @param {Ucanto.URI<"did:">} did
*/
async getDelegations(did) {
return await this.d1
.selectFrom('delegations')
.selectAll()
.where('delegations.audience', '=', did)
.execute()
}

/**
* @param {string} cid
*/
async getDelegationsByCid(cid) {
return await this.d1
.selectFrom('delegations')
.selectAll()
.where('delegations.cid', '=', cid)
.where((qb) =>
qb
.where('delegations.expires_at', '>=', new Date())
// eslint-disable-next-line unicorn/no-null
.orWhere('delegations.expires_at', 'is', null)
)
.executeTakeFirst()
}
}
20 changes: 14 additions & 6 deletions packages/access-api/src/models/spaces.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import * as Ucanto from '@ucanto/interface'
import { delegationToString } from '@web3-storage/access/encoding'
import { Kysely } from 'kysely'
import { D1Dialect } from 'kysely-d1'
import { D1Error, SpacePlugin } from '../utils/d1.js'
import { D1Error, GenericPlugin } from '../utils/d1.js'

const spacePlugin = new SpacePlugin()
/**
* @typedef {import('@web3-storage/access/src/types.js').SpaceRecord} SpaceRecord
*/

/**
* Spaces
Expand All @@ -17,8 +19,17 @@ export class Spaces {
* @param {D1Database} d1
*/
constructor(d1) {
/** @type {GenericPlugin<SpaceRecord>} */
const objectPlugin = new GenericPlugin({
metadata: (v) => JSON.parse(v),
inserted_at: (v) => new Date(v),
updated_at: (v) => new Date(v),
})
this.d1 = /** @type {Kysely<import('../bindings').D1Schema>} */ (
new Kysely({ dialect: new D1Dialect({ database: d1 }) })
new Kysely({
dialect: new D1Dialect({ database: d1 }),
plugins: [objectPlugin],
})
)
}

Expand All @@ -34,7 +45,6 @@ export class Spaces {
/** @type {unknown} */ (invocation.facts[0])
)
const result = await this.d1
.withPlugin(spacePlugin)
.insertInto('spaces')
.values({
agent: invocation.issuer.did(),
Expand Down Expand Up @@ -64,7 +74,6 @@ export class Spaces {
*/
async get(did) {
const space = await this.d1
.withPlugin(spacePlugin)
.selectFrom('spaces')
.selectAll()
.where('spaces.did', '=', did)
Expand All @@ -80,7 +89,6 @@ export class Spaces {
*/
async getByEmail(email) {
const spaces = await this.d1
.withPlugin(spacePlugin)
.selectFrom('spaces')
.selectAll()
.where('spaces.email', '=', email.replace('mailto:', ''))
Expand Down
12 changes: 12 additions & 0 deletions packages/access-api/src/models/validations.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ export class Validations {
return delegation
}

/**
* @template {import('@ucanto/interface').Capabilities} T = import('@ucanto/interface').Capabilities
* @param {import('@web3-storage/access/src/types').EncodedDelegation<T>} ucan
* @param {import('@ucanto/interface').DID} agent
* @param {number} expirationTtl - Expiration in second from now. Defaults to 5 mins.
*/
async putSession(ucan, agent, expirationTtl = 60 * 5) {
return await this.kv.put(agent, ucan, {
expirationTtl,
})
}

/**
* @param {string} did
*/
Expand Down
51 changes: 49 additions & 2 deletions packages/access-api/src/routes/validate-email.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/* eslint-disable no-unused-vars */
import { stringToDelegation } from '@web3-storage/access/encoding'
import QRCode from 'qrcode'
import { toEmail } from '../utils/did-mailto.js'
import {
HtmlResponse,
ValidateEmail,
Expand All @@ -14,6 +16,10 @@ export async function validateEmail(req, env) {
if (req.query && req.query.ucan && req.query.mode === 'recover') {
return recover(req, env)
}

if (req.query && req.query.ucan && req.query.mode === 'session') {
return session(req, env)
}
if (req.query && req.query.ucan) {
try {
const delegation = await env.models.validations.put(
Expand All @@ -25,7 +31,11 @@ export async function validateEmail(req, env) {
return new HtmlResponse(
(
<ValidateEmail
delegation={delegation}
email={delegation.capabilities[0].nb.identity.replace(
'mailto:',
''
)}
audience={delegation.audience.did()}
ucan={req.query.ucan}
qrcode={await QRCode.toString(req.query.ucan, {
type: 'svg',
Expand Down Expand Up @@ -71,7 +81,8 @@ async function recover(req, env) {
return new HtmlResponse(
(
<ValidateEmail
delegation={delegation}
email={delegation.capabilities[0].nb.identity.replace('mailto:', '')}
audience={delegation.audience.did()}
ucan={req.query.ucan}
qrcode={await QRCode.toString(req.query.ucan, {
type: 'svg',
Expand All @@ -96,3 +107,39 @@ async function recover(req, env) {
)
}
}

/**
* @param {import('@web3-storage/worker-utils/router').ParsedRequest} req
* @param {import('../bindings.js').RouteContext} env
*/
async function session(req, env) {
/** @type {import('@ucanto/interface').Delegation<[import('@web3-storage/capabilities/src/types.js').AccessSession]>} */
const delegation = stringToDelegation(req.query.ucan)
await env.models.validations.putSession(
req.query.ucan,
delegation.capabilities[0].nb.key
)

try {
return new HtmlResponse(
(
<ValidateEmail
email={toEmail(delegation.audience.did())}
audience={delegation.audience.did()}
ucan={req.query.ucan}
qrcode={await QRCode.toString(req.query.ucan, {
type: 'svg',
errorCorrectionLevel: 'M',
margin: 10,
})}
/>
)
)
} catch (error) {
const err = /** @type {Error} */ (error)
env.log.error(err)
return new HtmlResponse(
<ValidateEmailError msg={'Oops something went wrong.'} />
)
}
}
45 changes: 45 additions & 0 deletions packages/access-api/src/service/access-authorize.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// @ts-ignore
// eslint-disable-next-line no-unused-vars
import * as Ucanto from '@ucanto/interface'
import * as Server from '@ucanto/server'
import * as Access from '@web3-storage/capabilities/access'
import * as Mailto from '../utils/did-mailto.js'
import * as DID from '@ipld/dag-ucan/did'
import { delegationToString } from '@web3-storage/access/encoding'

/**
* @param {import('../bindings').RouteContext} ctx
*/
export function accessAuthorizeProvider(ctx) {
return Server.provide(
Access.authorize,
async ({ capability, invocation }) => {
const session = await Access.session
.invoke({
issuer: ctx.signer,
audience: DID.parse(capability.nb.as),
with: ctx.signer.did(),
lifetimeInSeconds: 86_400 * 7, // 7 days
nb: {
key: capability.with,
},
})
.delegate()

const encoded = delegationToString(session)

await ctx.models.accounts.create(capability.nb.as)

const url = `${ctx.url.protocol}//${ctx.url.host}/validate-email?ucan=${encoded}&mode=session`
// For testing
if (ctx.config.ENV === 'test') {
return url
}

await ctx.email.sendValidation({
to: Mailto.toEmail(capability.nb.as),
url,
})
}
)
}
Loading

0 comments on commit 9c8ca0b

Please sign in to comment.