Skip to content

Commit 1e43a31

Browse files
authored
feat!: rate-limit ucan auth requests (#2097)
1 parent b29d3d5 commit 1e43a31

10 files changed

Lines changed: 83 additions & 69 deletions

File tree

examples/ucan-node/index.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,11 @@ async function main() {
9898
// Sign a new request UCAN
9999
const ucan = await signRequestUCAN(kp, serviceDID, rootUCAN)
100100

101-
const storage = new NFTStorage({ endpoint, token: ucan })
101+
const storage = new NFTStorage({
102+
endpoint,
103+
token: ucan,
104+
did: kp.did(),
105+
})
102106
const data = await fs.promises.readFile('pinpie.jpg')
103107
const cid = await storage.storeBlob(new Blob([data]))
104108
console.log({ cid })

packages/api/src/errors.js

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,10 @@ export class ErrorDIDNotFound extends HTTPError {
239239
constructor(msg = 'User does not have a DID registered.') {
240240
super(msg, 400)
241241
this.name = 'DIDNotFound'
242-
this.code = ErrorMaintenance.CODE
242+
this.code = ErrorDIDNotFound.CODE
243243
}
244244
}
245-
ErrorMaintenance.CODE = 'ERROR_DID_NOT_FOUND'
245+
ErrorDIDNotFound.CODE = 'ERROR_DID_NOT_FOUND'
246246

247247
export class ErrorAgentDIDRequired extends HTTPError {
248248
constructor(
@@ -251,12 +251,8 @@ export class ErrorAgentDIDRequired extends HTTPError {
251251
) {
252252
super(msg, status)
253253
this.name = 'ErrorAgentDIDRequired'
254-
this.code = ErrorUnauthenticated.CODE
255-
}
256-
}
257-
258-
export class ErrorInvalidRoute extends HTTPError {
259-
constructor(msg = 'Invalid route for the request', status = 400) {
260-
super(msg, status)
254+
this.name = 'AgentDIDRequired'
255+
this.code = ErrorAgentDIDRequired.CODE
261256
}
262257
}
258+
ErrorAgentDIDRequired.CODE = 'ERROR_AGENT_DID_REQUIRED'

packages/api/src/index.js

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,6 @@ r.add('get', '/:cid', withAuth(withMode(nftGet, RO)), [postCors])
128128
r.add(
129129
'post',
130130
'/upload',
131-
withAuth(withMode(nftUpload, RW), {
132-
checkHasAccountRestriction,
133-
}),
134-
[postCors]
135-
)
136-
r.add(
137-
'post',
138-
'/ucan-upload',
139131
withAuth(withMode(nftUpload, RW), {
140132
checkHasAccountRestriction,
141133
checkUcan,

packages/api/src/utils/auth.js

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
ErrorUnauthenticated,
88
ErrorTokenBlocked,
99
ErrorAgentDIDRequired,
10-
ErrorInvalidRoute,
1110
} from '../errors.js'
1211
import { parseJWT, verifyJWT } from './jwt.js'
1312
import * as Ucan from 'ucan-storage/ucan-storage'
@@ -39,35 +38,29 @@ export async function validate(event, { log, db, ucanService }, options) {
3938
const auth = event.request.headers.get('Authorization') || ''
4039
const token = magic.utils.parseAuthorizationHeader(auth)
4140

42-
if (Ucan.isUcan(token)) {
43-
if (options?.checkUcan) {
44-
const agentDID = event.request.headers.get('x-agent-did') || ''
45-
if (!agentDID.startsWith('did:key:')) {
46-
throw new ErrorAgentDIDRequired()
47-
}
41+
if (options?.checkUcan && Ucan.isUcan(token)) {
42+
const agentDID = event.request.headers.get('x-agent-did') || ''
43+
if (!agentDID.startsWith('did:key:')) {
44+
throw new ErrorAgentDIDRequired()
45+
}
4846

49-
const { root, cap, issuer } = await ucanService.validateFromCaps(token)
50-
if (issuer !== agentDID) {
51-
throw new ErrorAgentDIDRequired(
52-
`Expected x-agent-did to be UCAN issuer DID: ${issuer}, instead got ${agentDID}`
53-
)
54-
}
55-
const user = await db.getUser(root.audience())
56-
if (user) {
57-
log.setUser({ id: user.id })
58-
return {
59-
user: filterDeletedKeys(user),
60-
db,
61-
ucan: { token, root: root._decoded.payload, cap },
62-
type: 'ucan',
63-
}
64-
} else {
65-
throw new ErrorTokenNotFound()
47+
const { root, cap, issuer } = await ucanService.validateFromCaps(token)
48+
if (issuer !== agentDID) {
49+
throw new ErrorAgentDIDRequired(
50+
`Expected x-agent-did to be UCAN issuer DID: ${issuer}, instead got ${agentDID}`
51+
)
52+
}
53+
const user = await db.getUser(root.audience())
54+
if (user) {
55+
log.setUser({ id: user.id })
56+
return {
57+
user: filterDeletedKeys(user),
58+
db,
59+
ucan: { token, root: root._decoded.payload, cap },
60+
type: 'ucan',
6661
}
6762
} else {
68-
throw new ErrorInvalidRoute(
69-
`Invalid route, UCAN authorized requests must be directed to /ucan-upload instead`
70-
)
63+
throw new ErrorTokenNotFound()
7164
}
7265
}
7366

packages/api/test/nfts-upload.spec.js

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -488,21 +488,9 @@ describe('NFT Upload ', () => {
488488
const file = new Blob(['hello world!'], { type: 'application/text' })
489489
// expected CID for the above data
490490
const cid = 'bafkreidvbhs33ighmljlvr7zbv2ywwzcmp5adtf4kqvlly67cy56bdtmve'
491-
{
492-
const res = await fetch('upload', {
493-
method: 'POST',
494-
headers: { Authorization: `Bearer ${opUcan}` },
495-
body: file,
496-
})
497-
498-
assert.equal(res.status, 400)
499-
const { ok, error } = await res.json()
500-
assert.equal(ok, false)
501-
assert.ok(error.message.match(/Invalid route/))
502-
}
503491

504492
{
505-
const res = await fetch('ucan-upload', {
493+
const res = await fetch('upload', {
506494
method: 'POST',
507495
headers: { Authorization: `Bearer ${opUcan}` },
508496
body: file,
@@ -516,7 +504,7 @@ describe('NFT Upload ', () => {
516504

517505
{
518506
const badkp = await KeyPair.create()
519-
const res = await fetch('ucan-upload', {
507+
const res = await fetch('upload', {
520508
method: 'POST',
521509
headers: {
522510
Authorization: `Bearer ${opUcan}`,
@@ -533,7 +521,7 @@ describe('NFT Upload ', () => {
533521
)
534522
}
535523

536-
const res = await fetch('ucan-upload', {
524+
const res = await fetch('upload', {
537525
method: 'POST',
538526
headers: { Authorization: `Bearer ${opUcan}`, 'x-agent-did': kp.did() },
539527
body: file,

packages/client/src/lib.js

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ const globalRateLimiter = createRateLimiter()
6666
*/
6767

6868
/**
69-
* @implements Service
69+
* @implements {Service}
7070
*/
7171
class NFTStorage {
7272
/**
@@ -90,10 +90,11 @@ class NFTStorage {
9090
* })
9191
* ```
9292
*
93-
* @param {{token: string, endpoint?: URL, rateLimiter?: RateLimiter}} options
93+
* @param {{token: string, endpoint?: URL, rateLimiter?: RateLimiter, did?: string}} options
9494
*/
9595
constructor({
9696
token,
97+
did,
9798
endpoint = new URL('https://api.nft.storage'),
9899
rateLimiter,
99100
}) {
@@ -112,15 +113,26 @@ class NFTStorage {
112113
* @readonly
113114
*/
114115
this.rateLimiter = rateLimiter || createRateLimiter()
116+
117+
/**
118+
* @readonly
119+
*/
120+
this.did = did
115121
}
116122

117123
/**
118124
* @hidden
119-
* @param {string} token
125+
* @param {object} options
126+
* @param {string} options.token
127+
* @param {string} [options.did]
120128
*/
121-
static auth(token) {
129+
static auth({ token, did }) {
122130
if (!token) throw new Error('missing token')
123-
return { Authorization: `Bearer ${token}`, 'X-Client': 'nft.storage/js' }
131+
return {
132+
Authorization: `Bearer ${token}`,
133+
'X-Client': 'nft.storage/js',
134+
...(did ? { 'x-agent-did': did } : {}),
135+
}
124136
}
125137

126138
/**
@@ -155,7 +167,7 @@ class NFTStorage {
155167
* @returns {Promise<CIDString>}
156168
*/
157169
static async storeCar(
158-
{ endpoint, token, rateLimiter = globalRateLimiter },
170+
{ endpoint, rateLimiter = globalRateLimiter, ...token },
159171
car,
160172
{ onStoredChunk, maxRetries, decoders, signal } = {}
161173
) {
@@ -289,7 +301,7 @@ class NFTStorage {
289301
* @returns {Promise<import('./lib/interface.js').StatusResult>}
290302
*/
291303
static async status(
292-
{ endpoint, token, rateLimiter = globalRateLimiter },
304+
{ endpoint, rateLimiter = globalRateLimiter, ...token },
293305
cid,
294306
options
295307
) {
@@ -365,7 +377,7 @@ class NFTStorage {
365377
* @returns {Promise<void>}
366378
*/
367379
static async delete(
368-
{ endpoint, token, rateLimiter = globalRateLimiter },
380+
{ endpoint, rateLimiter = globalRateLimiter, ...token },
369381
cid,
370382
options
371383
) {

packages/client/src/lib/interface.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export type Tagged<T, Tag> = T & { tag?: Tag }
1515
export interface Service {
1616
endpoint: URL
1717
token: string
18+
19+
did?: string
1820
rateLimiter?: RateLimiter
1921
}
2022

packages/client/test/lib.spec.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,25 @@ describe('client', () => {
3232
assert.equal(typeof NFTStorage.status, 'function')
3333
assert.equal(typeof NFTStorage.delete, 'function')
3434
})
35+
36+
describe('headers', () => {
37+
it('sets Authorization & X-Client headers', () => {
38+
const client = new NFTStorage({ token: 'secret' })
39+
assert.equal(NFTStorage.auth(client), {
40+
Authorization: 'Bearer secret',
41+
'X-Client': 'nft.storage/js',
42+
})
43+
})
44+
45+
it('sets x-agent-did header', () => {
46+
const client = new NFTStorage({ token: 'secret', did: 'did:key:zAlice' })
47+
assert.equal(NFTStorage.auth(client), {
48+
Authorization: 'Bearer secret',
49+
'X-Client': 'nft.storage/js',
50+
'x-agent-did': 'did:key:zAlice',
51+
})
52+
})
53+
})
3554
describe('upload', () => {
3655
it('upload blob', async () => {
3756
const client = new NFTStorage({ token, endpoint })

packages/website/pages/docs/how-to/ucan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ The `value` field contains the service DID, which is used when [creating request
155155

156156
## Sending requests
157157

158-
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.
158+
HTTP requests that use UCAN auth token must additionally set `x-agent-did` HTTP header to a DID that issued/signed the token.
159159

160160
## Getting help
161161

packages/website/public/schema.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,14 @@ paths:
145145
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).
146146
147147
operationId: upload
148+
parameters:
149+
- in: header
150+
name: x-agent-did
151+
description: DID that issued / signed UCAN authorization token (required if UCAN token is used)
152+
schema:
153+
type: string
154+
format: DID
155+
required: false
148156
requestBody:
149157
required: true
150158
content:

0 commit comments

Comments
 (0)