Skip to content

Commit b29d3d5

Browse files
GozalaAlan Shaw
andauthored
feat!: rate limiting by ucan agent did (#2093)
fixes #2092 Co-authored-by: Alan Shaw <alan.shaw@protocol.ai>
1 parent ae799be commit b29d3d5

8 files changed

Lines changed: 160 additions & 18 deletions

File tree

packages/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"nanoid": "^3.1.30",
3232
"regexparam": "^2.0.0",
3333
"toucan-js": "^2.4.1",
34-
"ucan-storage": "^1.0.0",
34+
"ucan-storage": "^1.3.0",
3535
"uint8arrays": "^3.0.0"
3636
},
3737
"devDependencies": {

packages/api/src/errors.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,20 @@ export class ErrorDIDNotFound extends HTTPError {
243243
}
244244
}
245245
ErrorMaintenance.CODE = 'ERROR_DID_NOT_FOUND'
246+
247+
export class ErrorAgentDIDRequired extends HTTPError {
248+
constructor(
249+
msg = 'UCAN authorized request must be supplied with x-agent-did header set to the DID of the UCAN issuer',
250+
status = 401
251+
) {
252+
super(msg, status)
253+
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)
261+
}
262+
}

packages/api/src/index.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,14 @@ 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',
131139
withAuth(withMode(nftUpload, RW), {
132140
checkHasAccountRestriction,
133141
checkUcan,

packages/api/src/utils/auth.js

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
ErrorTokenNotFound,
77
ErrorUnauthenticated,
88
ErrorTokenBlocked,
9+
ErrorAgentDIDRequired,
10+
ErrorInvalidRoute,
911
} from '../errors.js'
1012
import { parseJWT, verifyJWT } from './jwt.js'
1113
import * as Ucan from 'ucan-storage/ucan-storage'
@@ -37,19 +39,35 @@ export async function validate(event, { log, db, ucanService }, options) {
3739
const auth = event.request.headers.get('Authorization') || ''
3840
const token = magic.utils.parseAuthorizationHeader(auth)
3941

40-
if (options?.checkUcan && Ucan.isUcan(token)) {
41-
const { root, cap } = await ucanService.validateFromCaps(token)
42-
const user = await db.getUser(root.audience())
43-
if (user) {
44-
log.setUser({ id: user.id })
45-
return {
46-
user: filterDeletedKeys(user),
47-
db,
48-
ucan: { token, root: root._decoded.payload, cap },
49-
type: 'ucan',
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+
}
48+
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()
5066
}
5167
} else {
52-
throw new ErrorTokenNotFound()
68+
throw new ErrorInvalidRoute(
69+
`Invalid route, UCAN authorized requests must be directed to /ucan-upload instead`
70+
)
5371
}
5472
}
5573

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

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -488,11 +488,57 @@ 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-
const res = await fetch('upload', {
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+
}
503+
504+
{
505+
const res = await fetch('ucan-upload', {
506+
method: 'POST',
507+
headers: { Authorization: `Bearer ${opUcan}` },
508+
body: file,
509+
})
510+
511+
assert.equal(res.status, 401)
512+
const { ok, error } = await res.json()
513+
assert.equal(ok, false)
514+
assert.ok(error.message.match(/x-agent-did/))
515+
}
516+
517+
{
518+
const badkp = await KeyPair.create()
519+
const res = await fetch('ucan-upload', {
520+
method: 'POST',
521+
headers: {
522+
Authorization: `Bearer ${opUcan}`,
523+
'x-agent-did': badkp.did(),
524+
},
525+
body: file,
526+
})
527+
528+
assert.equal(res.status, 401)
529+
const { ok, error } = await res.json()
530+
assert.equal(ok, false)
531+
assert.ok(
532+
error.message.match(/Expected x-agent-did to be UCAN issuer DID/)
533+
)
534+
}
535+
536+
const res = await fetch('ucan-upload', {
492537
method: 'POST',
493-
headers: { Authorization: `Bearer ${opUcan}` },
538+
headers: { Authorization: `Bearer ${opUcan}`, 'x-agent-did': kp.did() },
494539
body: file,
495540
})
541+
496542
const { ok, value } = await res.json()
497543
assert(ok, 'Server response payload has `ok` property')
498544

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@ Send a `GET` request to `https://api.nft.storage/did`, which should return a JSO
153153

154154
The `value` field contains the service DID, which is used when [creating request tokens][ucan-storage-typedoc-creating-a-request-token].
155155

156+
## Sending requests
157+
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.
159+
156160
## Getting help
157161

158162
Use of UCANs to delegate upload permissions in NFT.Storage is currently a Preview Feature. If you find issues with the integration, need help with tooling, or have suggestions for improving the API for your use cases, please leave feedback in [this Github Discussion](https://github.com/nftstorage/nft.storage/discussions/1591). We're excited to see what you'll build!

packages/website/public/schema.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,55 @@ paths:
184184
$ref: '#/components/responses/forbidden'
185185
'500':
186186
$ref: '#/components/responses/internalServerError'
187+
/ucan-upload:
188+
post:
189+
tags:
190+
- NFT Storage
191+
summary: Store a file
192+
description: |
193+
Just like `/upload` endpoint but [using UCAN authorization tokens](https://nft.storage/docs/how-to/ucan/#using-ucans-with-nftstorage).
194+
You must set `x-agent-did` header to a DID that signed the token.
195+
196+
operationId: ucan-upload
197+
requestBody:
198+
required: true
199+
content:
200+
image/*:
201+
schema:
202+
type: string
203+
format: binary
204+
application/car:
205+
schema:
206+
type: string
207+
format: binary
208+
multipart/form-data:
209+
schema:
210+
type: object
211+
properties:
212+
file:
213+
type: array
214+
items:
215+
type: string
216+
format: binary
217+
'*/*':
218+
schema:
219+
type: string
220+
format: binary
221+
responses:
222+
'200':
223+
description: OK
224+
content:
225+
'application/json':
226+
schema:
227+
$ref: '#/components/schemas/UploadResponse'
228+
'400':
229+
$ref: '#/components/responses/badRequest'
230+
'401':
231+
$ref: '#/components/responses/unauthorized'
232+
'403':
233+
$ref: '#/components/responses/forbidden'
234+
'500':
235+
$ref: '#/components/responses/internalServerError'
187236
/:
188237
get:
189238
tags:

yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19758,10 +19758,10 @@ ua-parser-js@^1.0.2:
1975819758
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.2.tgz#e2976c34dbfb30b15d2c300b2a53eac87c57a775"
1975919759
integrity sha512-00y/AXhx0/SsnI51fTc0rLRmafiGOM4/O+ny10Ps7f+j/b8p/ZY11ytMgznXkOVo4GQ+KwQG5UQLkLGirsACRg==
1976019760

19761-
ucan-storage@^1.0.0:
19762-
version "1.2.0"
19763-
resolved "https://registry.yarnpkg.com/ucan-storage/-/ucan-storage-1.2.0.tgz#fa3899e86842cd6d832436303254512ee782a791"
19764-
integrity sha512-h3lDsuDVrKhgL8DBEMb1Fub+jvqp7jlOXJUaOTaKE5eXCG5IQZ1eol0YWOScSpjxOvpRwoYGNhEIZR0sb6DJaw==
19761+
"ucan-storage@^ 1.3.0":
19762+
version "1.3.0"
19763+
resolved "https://registry.yarnpkg.com/ucan-storage/-/ucan-storage-1.3.0.tgz#b9f3e29fa77da22a636ba5d917f4e747da0a89c8"
19764+
integrity sha512-C1PvShqWTg6JzcBAuWDeCsaL6AggwsGWqbvKZ8XdN9csAukQVnA5/kerddhdPrpeoCGnJFfSkvBcPklZzdJ+OQ==
1976519765
dependencies:
1976619766
"@noble/ed25519" "^1.5.2"
1976719767
base-x "^4.0.0"

0 commit comments

Comments
 (0)