diff --git a/examples/ucan-node/index.js b/examples/ucan-node/index.js index 7351e97af9..be3197190c 100644 --- a/examples/ucan-node/index.js +++ b/examples/ucan-node/index.js @@ -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 }) diff --git a/packages/api/src/errors.js b/packages/api/src/errors.js index 4dbacc7d0c..78896afd71 100644 --- a/packages/api/src/errors.js +++ b/packages/api/src/errors.js @@ -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( @@ -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' diff --git a/packages/api/src/index.js b/packages/api/src/index.js index a8b61fb094..1ef0931dfa 100644 --- a/packages/api/src/index.js +++ b/packages/api/src/index.js @@ -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, diff --git a/packages/api/src/utils/auth.js b/packages/api/src/utils/auth.js index 0265200801..674a7abb48 100644 --- a/packages/api/src/utils/auth.js +++ b/packages/api/src/utils/auth.js @@ -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' @@ -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() } } diff --git a/packages/api/test/nfts-upload.spec.js b/packages/api/test/nfts-upload.spec.js index 6d5c14ed17..9939ce8d39 100644 --- a/packages/api/test/nfts-upload.spec.js +++ b/packages/api/test/nfts-upload.spec.js @@ -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, @@ -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}`, @@ -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, diff --git a/packages/client/src/lib.js b/packages/client/src/lib.js index 8a448e65ff..9f991bbe3d 100644 --- a/packages/client/src/lib.js +++ b/packages/client/src/lib.js @@ -66,7 +66,7 @@ const globalRateLimiter = createRateLimiter() */ /** - * @implements Service + * @implements {Service} */ class NFTStorage { /** @@ -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, }) { @@ -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 } : {}), + } } /** @@ -155,7 +167,7 @@ class NFTStorage { * @returns {Promise} */ static async storeCar( - { endpoint, token, rateLimiter = globalRateLimiter }, + { endpoint, rateLimiter = globalRateLimiter, ...token }, car, { onStoredChunk, maxRetries, decoders, signal } = {} ) { @@ -289,7 +301,7 @@ class NFTStorage { * @returns {Promise} */ static async status( - { endpoint, token, rateLimiter = globalRateLimiter }, + { endpoint, rateLimiter = globalRateLimiter, ...token }, cid, options ) { @@ -365,7 +377,7 @@ class NFTStorage { * @returns {Promise} */ static async delete( - { endpoint, token, rateLimiter = globalRateLimiter }, + { endpoint, rateLimiter = globalRateLimiter, ...token }, cid, options ) { diff --git a/packages/client/src/lib/interface.ts b/packages/client/src/lib/interface.ts index 995b900c9d..cb68bb1d43 100644 --- a/packages/client/src/lib/interface.ts +++ b/packages/client/src/lib/interface.ts @@ -15,6 +15,8 @@ export type Tagged = T & { tag?: Tag } export interface Service { endpoint: URL token: string + + did?: string rateLimiter?: RateLimiter } diff --git a/packages/client/test/lib.spec.js b/packages/client/test/lib.spec.js index 38e8b19809..e486ab4101 100644 --- a/packages/client/test/lib.spec.js +++ b/packages/client/test/lib.spec.js @@ -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 }) diff --git a/packages/website/pages/docs/how-to/ucan.md b/packages/website/pages/docs/how-to/ucan.md index f52f4150a3..1654a1fca2 100644 --- a/packages/website/pages/docs/how-to/ucan.md +++ b/packages/website/pages/docs/how-to/ucan.md @@ -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 diff --git a/packages/website/public/schema.yml b/packages/website/public/schema.yml index ef17dbf685..ea8e4dc1d3 100644 --- a/packages/website/public/schema.yml +++ b/packages/website/public/schema.yml @@ -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: