Skip to content

Commit

Permalink
feat: support ucan as bearer (#50)
Browse files Browse the repository at this point in the history
  • Loading branch information
hugomrdias authored Sep 6, 2022
1 parent d8eeaa0 commit bc73755
Show file tree
Hide file tree
Showing 5 changed files with 204 additions and 38 deletions.
8 changes: 4 additions & 4 deletions packages/access-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"deploy": "wrangler publish",
"dev": "miniflare --watch --debug --env ../../.env --wrangler-env dev",
"build": "scripts/cli.js build",
"test": "tsc --build && ava"
"test": "tsc --build && ava --serial"
},
"author": "Hugo Dias <hugomrdias@gmail.com> (hugodias.me)",
"license": "(Apache-2.0 OR MIT)",
Expand All @@ -24,13 +24,13 @@
"@ucanto/transport": "^0.7.0",
"@ucanto/validator": "^0.6.0",
"@web3-storage/access": "workspace:^",
"@web3-storage/worker-utils": "0.2.0-dev",
"@web3-storage/worker-utils": "0.4.3-dev",
"multiformats": "^9.6.5",
"nanoid": "^4.0.0",
"toucan-js": "^2.6.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^3.15.0",
"@cloudflare/workers-types": "^3.16.0",
"@sentry/cli": "^2.5.2",
"@sentry/webpack-plugin": "^1.16.0",
"@types/assert": "^1.5.6",
Expand All @@ -40,7 +40,7 @@
"ava": "^4.3.3",
"buffer": "^6.0.3",
"delay": "^5.0.0",
"dotenv": "^16.0.0",
"dotenv": "^16.0.2",
"esbuild": "^0.15.6",
"execa": "^6.1.0",
"git-rev-sync": "^3.0.1",
Expand Down
115 changes: 97 additions & 18 deletions packages/access-api/src/ucanto/server-codec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,74 @@ import * as UCAN from '@ipld/dag-ucan'
import { UTF8 } from '@ucanto/transport'
// eslint-disable-next-line no-unused-vars
import * as Types from '@ucanto/interface'
import { HTTPError } from '@web3-storage/worker-utils/error'

const HEADERS = Object.freeze({
'content-type': 'application/json',
})

/**
* Split multi value headers into an array
*
* @param {Headers} headers
* @param {string} name
*/
function multiValueHeader(headers, name) {
const out = headers
.get(name)
?.split(',')
.map((v) => v.trimStart())

return out || []
}

/**
* @param {Record<string, string>} headers
*/
async function parseHeaders(headers) {
const h = new Headers(headers)

try {
/** @type Map<string,UCAN.Block> */
const proofs = new Map()
const proofsRaw = multiValueHeader(h, 'ucan')

for (const cidUcan of proofsRaw) {
const [cid, ucan] = cidUcan.trim().split(/\s+/)
const ucanView = UCAN.parse(/** @type {UCAN.JWT<any>} */ (ucan))
const block = await UCAN.write(ucanView)

if (cid !== block.cid.toString()) {
throw new TypeError(
`Invalid request, proof with cid ${block.cid.toString()} has mismatching cid ${cid} in the header`
)
}

proofs.set(cid, block)
}

/** @type {UCAN.View<any>[]} */
const ucans = []
const auths = multiValueHeader(h, 'Authorization')

for (const auth of auths) {
if (auth.toLowerCase().startsWith('bearer ')) {
const ucanView = UCAN.parse(
/** @type {UCAN.JWT<any>} */ (auth.slice(7))
)
ucans.push(ucanView)
}
}

return { proofs, ucans }
} catch (error) {
throw new HTTPError('Malformed UCAN headers data.', {
status: 401,
cause: /** @type {Error} */ (error),
})
}
}

/** @type {import('./types.js').ServerCodec} */
export const serverCodec = {
/**
Expand All @@ -17,29 +80,45 @@ export const serverCodec = {
* @param {Types.HTTPRequest<I>} request
*/
async decode({ body, headers }) {
const bearer = headers.authorization || ''
if (!bearer.toLowerCase().startsWith('bearer ')) {
throw Object.assign(new Error('bearer missing.'), {
status: 400,
})
const headersData = await parseHeaders(headers)
if (headersData.ucans.length === 0) {
throw new HTTPError(
'The required "Authorization: Bearer" header is missing.',
{ status: 400 }
)
}

const jwt = bearer.slice(7)
const invocations = []
try {
const data = UCAN.parse(/** @type {UCAN.JWT<any>} */ (jwt))
const root = await UCAN.write(data)

invocations.push(Delegation.create({ root }))
return /** @type {Types.InferInvocations<I>} */ (invocations)
} catch (error) {
throw Object.assign(
new Error('Invalid JWT.', { cause: /** @type {Error} */ (error) }),
{
status: 400,

// Iterate ucan invocations from the headers
for (const ucanView of headersData.ucans) {
const blocks = new Map()
const missing = []

// Check all the proofs for each invocation
for (const proofCID of ucanView.proofs) {
const proof = headersData.proofs.get(proofCID.toString())
if (!proof) {
missing.push(proofCID.toString())
}

blocks.set(proofCID.toString(), proof)
// TODO implement caching of proofs https://github.com/ucan-wg/ucan-as-bearer-token#32-cache-and-expiry
}

if (missing.length > 0) {
throw new HTTPError('Missing Proofs', {
status: 510,
// @ts-ignore - cause type is a mess
cause: { prf: missing },
})
}

// Build the full ucan chain for each invocation from headers data
invocations.push(
Delegation.create({ root: await UCAN.write(ucanView), blocks })
)
}
return /** @type {Types.InferInvocations<I>} */ (invocations)
},

/**
Expand Down
1 change: 1 addition & 0 deletions packages/access-api/src/utils/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function getContext(event, params) {
branch: config.BRANCH,
version: config.VERSION,
commit: config.COMMITHASH,
env: config.ENV,
}
)

Expand Down
94 changes: 90 additions & 4 deletions packages/access-api/test/ucan.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ test('should fail with no header', async (t) => {
})
const rsp = await res.json()
t.deepEqual(rsp, {
error: { code: 'HTTP_ERROR', message: 'bearer missing.' },
error: {
code: 'HTTP_ERROR',
message: 'The required "Authorization: Bearer" header is missing.',
},
})
t.is(res.status, 400)
})
Expand All @@ -27,14 +30,14 @@ test('should fail with bad ucan', async (t) => {
Authorization: `Bearer ss`,
},
})
t.is(res.status, 400)
t.is(res.status, 401)
const rsp = await res.json()
t.deepEqual(rsp, {
error: {
code: 'HTTP_ERROR',
message: 'Invalid JWT.',
message: 'Malformed UCAN headers data.',
cause:
"Can't parse UCAN: ss: Expected JWT format: 3 dot-separated base64url-encoded values.",
"ParseError: Can't parse UCAN: ss: Expected JWT format: 3 dot-separated base64url-encoded values.",
},
})
})
Expand Down Expand Up @@ -168,3 +171,86 @@ test('should handle exception in route handler', async (t) => {
'service handler {can: "testing/fail"} error: test fail'
)
})

test('should fail with missing proofs', async (t) => {
const { mf } = t.context

const alice = await SigningAuthority.generate()
const bob = await SigningAuthority.generate()
const proof1 = await UCAN.issue({
issuer: alice,
audience: bob,
capabilities: [{ can: 'testing/pass', with: 'mailto:admin@dag.house' }],
})

const proof2 = await UCAN.issue({
issuer: alice,
audience: bob,
capabilities: [{ can: 'testing/pass', with: 'mailto:admin@dag.house' }],
})
const cid1 = await UCAN.link(proof1)
const cid2 = await UCAN.link(proof2)
const ucan = await UCAN.issue({
issuer: bob,
audience: serviceAuthority,
capabilities: [{ can: 'testing/pass', with: 'mailto:admin@dag.house' }],
proofs: [cid1, cid2],
})

const res = await mf.dispatchFetch('http://localhost:8787/raw', {
method: 'POST',
headers: {
Authorization: `Bearer ${UCAN.format(ucan)}`,
},
})
const rsp = await res.json()
t.deepEqual(rsp, {
error: {
code: 'HTTP_ERROR',
message: 'Missing Proofs',
cause: {
prf: [cid1.toString(), cid2.toString()],
},
},
})
})

test('should multiple invocation should pass', async (t) => {
const { mf } = t.context

const alice = await SigningAuthority.generate()
const bob = await SigningAuthority.generate()
const proof1 = await UCAN.issue({
issuer: alice,
audience: bob,
capabilities: [{ can: 'testing/pass', with: 'mailto:admin@dag.house' }],
})

const cid1 = await UCAN.link(proof1)
const ucan1 = await UCAN.issue({
issuer: bob,
audience: serviceAuthority,
capabilities: [{ can: 'testing/pass', with: 'mailto:admin@dag.house' }],
proofs: [cid1],
})

const ucan2 = await UCAN.issue({
issuer: bob,
audience: serviceAuthority,
capabilities: [{ can: 'testing/pass', with: 'mailto:admin@dag.house' }],
proofs: [cid1],
})

const headers = new Headers()
headers.append('Authorization', `Bearer ${UCAN.format(ucan1)}`)
headers.append('Authorization', `Bearer ${UCAN.format(ucan2)}`)
headers.append('ucan', `${cid1.toString()} ${UCAN.format(proof1)}`)

const res = await mf.dispatchFetch('http://localhost:8787/raw', {
method: 'POST',
headers,
})

const rsp = await res.json()
t.deepEqual(rsp, ['test pass', 'test pass'])
})
24 changes: 12 additions & 12 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit bc73755

Please sign in to comment.