Skip to content

Commit

Permalink
feat!: rate-limit ucan auth requests (#2097)
Browse files Browse the repository at this point in the history
  • Loading branch information
Gozala authored Jul 29, 2022
1 parent b29d3d5 commit 1e43a31
Show file tree
Hide file tree
Showing 10 changed files with 83 additions and 69 deletions.
6 changes: 5 additions & 1 deletion examples/ucan-node/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,11 @@ async function main() {
// Sign a new request UCAN
const ucan = await signRequestUCAN(kp, serviceDID, rootUCAN)

const storage = new NFTStorage({ endpoint, token: ucan })
const storage = new NFTStorage({
endpoint,
token: ucan,
did: kp.did(),
})
const data = await fs.promises.readFile('pinpie.jpg')
const cid = await storage.storeBlob(new Blob([data]))
console.log({ cid })
Expand Down
14 changes: 5 additions & 9 deletions packages/api/src/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,10 @@ export class ErrorDIDNotFound extends HTTPError {
constructor(msg = 'User does not have a DID registered.') {
super(msg, 400)
this.name = 'DIDNotFound'
this.code = ErrorMaintenance.CODE
this.code = ErrorDIDNotFound.CODE
}
}
ErrorMaintenance.CODE = 'ERROR_DID_NOT_FOUND'
ErrorDIDNotFound.CODE = 'ERROR_DID_NOT_FOUND'

export class ErrorAgentDIDRequired extends HTTPError {
constructor(
Expand All @@ -251,12 +251,8 @@ export class ErrorAgentDIDRequired extends HTTPError {
) {
super(msg, status)
this.name = 'ErrorAgentDIDRequired'
this.code = ErrorUnauthenticated.CODE
}
}

export class ErrorInvalidRoute extends HTTPError {
constructor(msg = 'Invalid route for the request', status = 400) {
super(msg, status)
this.name = 'AgentDIDRequired'
this.code = ErrorAgentDIDRequired.CODE
}
}
ErrorAgentDIDRequired.CODE = 'ERROR_AGENT_DID_REQUIRED'
8 changes: 0 additions & 8 deletions packages/api/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,6 @@ r.add('get', '/:cid', withAuth(withMode(nftGet, RO)), [postCors])
r.add(
'post',
'/upload',
withAuth(withMode(nftUpload, RW), {
checkHasAccountRestriction,
}),
[postCors]
)
r.add(
'post',
'/ucan-upload',
withAuth(withMode(nftUpload, RW), {
checkHasAccountRestriction,
checkUcan,
Expand Down
47 changes: 20 additions & 27 deletions packages/api/src/utils/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
ErrorUnauthenticated,
ErrorTokenBlocked,
ErrorAgentDIDRequired,
ErrorInvalidRoute,
} from '../errors.js'
import { parseJWT, verifyJWT } from './jwt.js'
import * as Ucan from 'ucan-storage/ucan-storage'
Expand Down Expand Up @@ -39,35 +38,29 @@ export async function validate(event, { log, db, ucanService }, options) {
const auth = event.request.headers.get('Authorization') || ''
const token = magic.utils.parseAuthorizationHeader(auth)

if (Ucan.isUcan(token)) {
if (options?.checkUcan) {
const agentDID = event.request.headers.get('x-agent-did') || ''
if (!agentDID.startsWith('did:key:')) {
throw new ErrorAgentDIDRequired()
}
if (options?.checkUcan && Ucan.isUcan(token)) {
const agentDID = event.request.headers.get('x-agent-did') || ''
if (!agentDID.startsWith('did:key:')) {
throw new ErrorAgentDIDRequired()
}

const { root, cap, issuer } = await ucanService.validateFromCaps(token)
if (issuer !== agentDID) {
throw new ErrorAgentDIDRequired(
`Expected x-agent-did to be UCAN issuer DID: ${issuer}, instead got ${agentDID}`
)
}
const user = await db.getUser(root.audience())
if (user) {
log.setUser({ id: user.id })
return {
user: filterDeletedKeys(user),
db,
ucan: { token, root: root._decoded.payload, cap },
type: 'ucan',
}
} else {
throw new ErrorTokenNotFound()
const { root, cap, issuer } = await ucanService.validateFromCaps(token)
if (issuer !== agentDID) {
throw new ErrorAgentDIDRequired(
`Expected x-agent-did to be UCAN issuer DID: ${issuer}, instead got ${agentDID}`
)
}
const user = await db.getUser(root.audience())
if (user) {
log.setUser({ id: user.id })
return {
user: filterDeletedKeys(user),
db,
ucan: { token, root: root._decoded.payload, cap },
type: 'ucan',
}
} else {
throw new ErrorInvalidRoute(
`Invalid route, UCAN authorized requests must be directed to /ucan-upload instead`
)
throw new ErrorTokenNotFound()
}
}

Expand Down
18 changes: 3 additions & 15 deletions packages/api/test/nfts-upload.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -488,21 +488,9 @@ describe('NFT Upload ', () => {
const file = new Blob(['hello world!'], { type: 'application/text' })
// expected CID for the above data
const cid = 'bafkreidvbhs33ighmljlvr7zbv2ywwzcmp5adtf4kqvlly67cy56bdtmve'
{
const res = await fetch('upload', {
method: 'POST',
headers: { Authorization: `Bearer ${opUcan}` },
body: file,
})

assert.equal(res.status, 400)
const { ok, error } = await res.json()
assert.equal(ok, false)
assert.ok(error.message.match(/Invalid route/))
}

{
const res = await fetch('ucan-upload', {
const res = await fetch('upload', {
method: 'POST',
headers: { Authorization: `Bearer ${opUcan}` },
body: file,
Expand All @@ -516,7 +504,7 @@ describe('NFT Upload ', () => {

{
const badkp = await KeyPair.create()
const res = await fetch('ucan-upload', {
const res = await fetch('upload', {
method: 'POST',
headers: {
Authorization: `Bearer ${opUcan}`,
Expand All @@ -533,7 +521,7 @@ describe('NFT Upload ', () => {
)
}

const res = await fetch('ucan-upload', {
const res = await fetch('upload', {
method: 'POST',
headers: { Authorization: `Bearer ${opUcan}`, 'x-agent-did': kp.did() },
body: file,
Expand Down
28 changes: 20 additions & 8 deletions packages/client/src/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const globalRateLimiter = createRateLimiter()
*/

/**
* @implements Service
* @implements {Service}
*/
class NFTStorage {
/**
Expand All @@ -90,10 +90,11 @@ class NFTStorage {
* })
* ```
*
* @param {{token: string, endpoint?: URL, rateLimiter?: RateLimiter}} options
* @param {{token: string, endpoint?: URL, rateLimiter?: RateLimiter, did?: string}} options
*/
constructor({
token,
did,
endpoint = new URL('https://api.nft.storage'),
rateLimiter,
}) {
Expand All @@ -112,15 +113,26 @@ class NFTStorage {
* @readonly
*/
this.rateLimiter = rateLimiter || createRateLimiter()

/**
* @readonly
*/
this.did = did
}

/**
* @hidden
* @param {string} token
* @param {object} options
* @param {string} options.token
* @param {string} [options.did]
*/
static auth(token) {
static auth({ token, did }) {
if (!token) throw new Error('missing token')
return { Authorization: `Bearer ${token}`, 'X-Client': 'nft.storage/js' }
return {
Authorization: `Bearer ${token}`,
'X-Client': 'nft.storage/js',
...(did ? { 'x-agent-did': did } : {}),
}
}

/**
Expand Down Expand Up @@ -155,7 +167,7 @@ class NFTStorage {
* @returns {Promise<CIDString>}
*/
static async storeCar(
{ endpoint, token, rateLimiter = globalRateLimiter },
{ endpoint, rateLimiter = globalRateLimiter, ...token },
car,
{ onStoredChunk, maxRetries, decoders, signal } = {}
) {
Expand Down Expand Up @@ -289,7 +301,7 @@ class NFTStorage {
* @returns {Promise<import('./lib/interface.js').StatusResult>}
*/
static async status(
{ endpoint, token, rateLimiter = globalRateLimiter },
{ endpoint, rateLimiter = globalRateLimiter, ...token },
cid,
options
) {
Expand Down Expand Up @@ -365,7 +377,7 @@ class NFTStorage {
* @returns {Promise<void>}
*/
static async delete(
{ endpoint, token, rateLimiter = globalRateLimiter },
{ endpoint, rateLimiter = globalRateLimiter, ...token },
cid,
options
) {
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/lib/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export type Tagged<T, Tag> = T & { tag?: Tag }
export interface Service {
endpoint: URL
token: string

did?: string
rateLimiter?: RateLimiter
}

Expand Down
19 changes: 19 additions & 0 deletions packages/client/test/lib.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,25 @@ describe('client', () => {
assert.equal(typeof NFTStorage.status, 'function')
assert.equal(typeof NFTStorage.delete, 'function')
})

describe('headers', () => {
it('sets Authorization & X-Client headers', () => {
const client = new NFTStorage({ token: 'secret' })
assert.equal(NFTStorage.auth(client), {
Authorization: 'Bearer secret',
'X-Client': 'nft.storage/js',
})
})

it('sets x-agent-did header', () => {
const client = new NFTStorage({ token: 'secret', did: 'did:key:zAlice' })
assert.equal(NFTStorage.auth(client), {
Authorization: 'Bearer secret',
'X-Client': 'nft.storage/js',
'x-agent-did': 'did:key:zAlice',
})
})
})
describe('upload', () => {
it('upload blob', async () => {
const client = new NFTStorage({ token, endpoint })
Expand Down
2 changes: 1 addition & 1 deletion packages/website/pages/docs/how-to/ucan.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ The `value` field contains the service DID, which is used when [creating request

## Sending requests

You can send upload requests with UCAN auth token to `https://api.nft.storage/ucan-upload` endpoint. Requests to that endpoint must have additional `x-agent-did` HTTP header with a value set to a DID token was issued/signed by.
HTTP requests that use UCAN auth token must additionally set `x-agent-did` HTTP header to a DID that issued/signed the token.

## Getting help

Expand Down
8 changes: 8 additions & 0 deletions packages/website/public/schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ paths:
This API imposes rate limits to ensure quality of service. You may receive a 429 "Too many requests" error if you make more than 30 requests with the same API token within a ten second window. Upon receiving a response with a 429 status, clients should retry the failed request after a small delay. To avoid 429 responses, you may wish to implement client-side request throttling to stay within the limits (note: the JS client automatically does this).
operationId: upload
parameters:
- in: header
name: x-agent-did
description: DID that issued / signed UCAN authorization token (required if UCAN token is used)
schema:
type: string
format: DID
required: false
requestBody:
required: true
content:
Expand Down

0 comments on commit 1e43a31

Please sign in to comment.