diff --git a/packages/api/package.json b/packages/api/package.json index 62990ef779..22f610885d 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -31,7 +31,7 @@ "nanoid": "^3.1.30", "regexparam": "^2.0.0", "toucan-js": "^2.4.1", - "ucan-storage": "^1.0.0", + "ucan-storage": "^1.3.0", "uint8arrays": "^3.0.0" }, "devDependencies": { diff --git a/packages/api/src/errors.js b/packages/api/src/errors.js index 61ba1896b5..4dbacc7d0c 100644 --- a/packages/api/src/errors.js +++ b/packages/api/src/errors.js @@ -243,3 +243,20 @@ export class ErrorDIDNotFound extends HTTPError { } } ErrorMaintenance.CODE = 'ERROR_DID_NOT_FOUND' + +export class ErrorAgentDIDRequired extends HTTPError { + constructor( + msg = 'UCAN authorized request must be supplied with x-agent-did header set to the DID of the UCAN issuer', + status = 401 + ) { + 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) + } +} diff --git a/packages/api/src/index.js b/packages/api/src/index.js index 1ef0931dfa..a8b61fb094 100644 --- a/packages/api/src/index.js +++ b/packages/api/src/index.js @@ -128,6 +128,14 @@ 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, diff --git a/packages/api/src/utils/auth.js b/packages/api/src/utils/auth.js index adfb88e5a0..0265200801 100644 --- a/packages/api/src/utils/auth.js +++ b/packages/api/src/utils/auth.js @@ -6,6 +6,8 @@ import { ErrorTokenNotFound, ErrorUnauthenticated, ErrorTokenBlocked, + ErrorAgentDIDRequired, + ErrorInvalidRoute, } from '../errors.js' import { parseJWT, verifyJWT } from './jwt.js' import * as Ucan from 'ucan-storage/ucan-storage' @@ -37,19 +39,35 @@ export async function validate(event, { log, db, ucanService }, options) { const auth = event.request.headers.get('Authorization') || '' const token = magic.utils.parseAuthorizationHeader(auth) - if (options?.checkUcan && Ucan.isUcan(token)) { - const { root, cap } = await ucanService.validateFromCaps(token) - 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', + if (Ucan.isUcan(token)) { + if (options?.checkUcan) { + 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() } } else { - throw new ErrorTokenNotFound() + throw new ErrorInvalidRoute( + `Invalid route, UCAN authorized requests must be directed to /ucan-upload instead` + ) } } diff --git a/packages/api/test/nfts-upload.spec.js b/packages/api/test/nfts-upload.spec.js index 756325e532..6d5c14ed17 100644 --- a/packages/api/test/nfts-upload.spec.js +++ b/packages/api/test/nfts-upload.spec.js @@ -488,11 +488,57 @@ 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', { + { + 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', { + method: 'POST', + headers: { Authorization: `Bearer ${opUcan}` }, + body: file, + }) + + assert.equal(res.status, 401) + const { ok, error } = await res.json() + assert.equal(ok, false) + assert.ok(error.message.match(/x-agent-did/)) + } + + { + const badkp = await KeyPair.create() + const res = await fetch('ucan-upload', { + method: 'POST', + headers: { + Authorization: `Bearer ${opUcan}`, + 'x-agent-did': badkp.did(), + }, + body: file, + }) + + assert.equal(res.status, 401) + const { ok, error } = await res.json() + assert.equal(ok, false) + assert.ok( + error.message.match(/Expected x-agent-did to be UCAN issuer DID/) + ) + } + + const res = await fetch('ucan-upload', { method: 'POST', - headers: { Authorization: `Bearer ${opUcan}` }, + headers: { Authorization: `Bearer ${opUcan}`, 'x-agent-did': kp.did() }, body: file, }) + const { ok, value } = await res.json() assert(ok, 'Server response payload has `ok` property') diff --git a/packages/website/pages/docs/how-to/ucan.md b/packages/website/pages/docs/how-to/ucan.md index 96de2b382b..f52f4150a3 100644 --- a/packages/website/pages/docs/how-to/ucan.md +++ b/packages/website/pages/docs/how-to/ucan.md @@ -153,6 +153,10 @@ Send a `GET` request to `https://api.nft.storage/did`, which should return a JSO The `value` field contains the service DID, which is used when [creating request tokens][ucan-storage-typedoc-creating-a-request-token]. +## 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. + ## Getting help 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! diff --git a/packages/website/public/schema.yml b/packages/website/public/schema.yml index 5bf57b0c60..ef17dbf685 100644 --- a/packages/website/public/schema.yml +++ b/packages/website/public/schema.yml @@ -184,6 +184,55 @@ paths: $ref: '#/components/responses/forbidden' '500': $ref: '#/components/responses/internalServerError' + /ucan-upload: + post: + tags: + - NFT Storage + summary: Store a file + description: | + Just like `/upload` endpoint but [using UCAN authorization tokens](https://nft.storage/docs/how-to/ucan/#using-ucans-with-nftstorage). + You must set `x-agent-did` header to a DID that signed the token. + + operationId: ucan-upload + requestBody: + required: true + content: + image/*: + schema: + type: string + format: binary + application/car: + schema: + type: string + format: binary + multipart/form-data: + schema: + type: object + properties: + file: + type: array + items: + type: string + format: binary + '*/*': + schema: + type: string + format: binary + responses: + '200': + description: OK + content: + 'application/json': + schema: + $ref: '#/components/schemas/UploadResponse' + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '500': + $ref: '#/components/responses/internalServerError' /: get: tags: diff --git a/yarn.lock b/yarn.lock index c0bf0c6cac..7e7b41987f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19758,10 +19758,10 @@ ua-parser-js@^1.0.2: resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.2.tgz#e2976c34dbfb30b15d2c300b2a53eac87c57a775" integrity sha512-00y/AXhx0/SsnI51fTc0rLRmafiGOM4/O+ny10Ps7f+j/b8p/ZY11ytMgznXkOVo4GQ+KwQG5UQLkLGirsACRg== -ucan-storage@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/ucan-storage/-/ucan-storage-1.2.0.tgz#fa3899e86842cd6d832436303254512ee782a791" - integrity sha512-h3lDsuDVrKhgL8DBEMb1Fub+jvqp7jlOXJUaOTaKE5eXCG5IQZ1eol0YWOScSpjxOvpRwoYGNhEIZR0sb6DJaw== +"ucan-storage@^ 1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/ucan-storage/-/ucan-storage-1.3.0.tgz#b9f3e29fa77da22a636ba5d917f4e747da0a89c8" + integrity sha512-C1PvShqWTg6JzcBAuWDeCsaL6AggwsGWqbvKZ8XdN9csAukQVnA5/kerddhdPrpeoCGnJFfSkvBcPklZzdJ+OQ== dependencies: "@noble/ed25519" "^1.5.2" base-x "^4.0.0"