diff --git a/src/http-api.ts b/src/http-api.ts index ed18ee2..21f3a36 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -153,6 +153,19 @@ export class HttpApi { return res.status(400).json(reply); } + if ( + config.powRegistration && + !(await this.#pow.isAuthorized(dwnRpcRequest.params.target)) + ) { + const reply = createJsonRpcErrorResponse( + dwnRpcRequest.id || uuidv4(), + JsonRpcErrorCodes.Forbidden, + 'tenant not authorized, please register first', + ); + + return res.status(403).json(reply); + } + // Check whether data was provided in the request body const contentLength = req.headers['content-length']; const transferEncoding = req.headers['transfer-encoding']; diff --git a/src/pow.ts b/src/pow.ts index d02b4e5..d3ae511 100644 --- a/src/pow.ts +++ b/src/pow.ts @@ -42,6 +42,16 @@ export class ProofOfWork { ); } + async isAuthorized(tenant: string): Promise { + const result = await this.#db + .selectFrom('authorizedTenants') + .select('did') + .where('did', '=', tenant) + .execute(); + + return result.length > 0; + } + private async getChallenge(_req: Request, res: Response): Promise { const challenge = generateChallenge(); recentChallenges[challenge] = Date.now(); @@ -63,7 +73,9 @@ export class ProofOfWork { hash.update(body.response); const complexity = getComplexity(); - if (!hash.digest('hex').startsWith('0'.repeat(complexity))) { + const digest = hash.digest('hex'); + console.log('digest: ', digest); + if (!digest.startsWith('0'.repeat(complexity))) { res.status(401).json({ success: false }); return; } diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index 93bed18..c04c68b 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -25,6 +25,7 @@ import { JsonRpcErrorCodes, } from '../src/lib/json-rpc.js'; import { clear as clearDwn, dwn } from './test-dwn.js'; +import type { Profile } from './utils.js'; import { createProfile, createRecordsWriteMessage, @@ -40,9 +41,12 @@ if (!globalThis.crypto) { describe('http api', function () { let httpApi: HttpApi; let server: Server; + let profile: Profile; before(async function () { config.powRegistration = true; + config.tenantRegistrationStore = 'sqlite://'; // use in-memory database that doesn't persist after tests have run + profile = await createProfile(); httpApi = new HttpApi(dwn); }); @@ -56,111 +60,243 @@ describe('http api', function () { await clearDwn(); }); - it('responds with a 400 if no dwn-request header is provided', async function () { - const response = await request(httpApi.api).post('/').send(); + describe('/register', function () { + it('returns a register challenge', async function () { + const response = await fetch('http://localhost:3000/register'); + expect(response.status).to.equal(200); + const body = (await response.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(10); + expect(body.complexity).to.equal(1); + }); - expect(response.statusCode).to.equal(400); + it('accepts a correct registration challenge', async function () { + const challengeResponse = await fetch('http://localhost:3000/register'); + expect(challengeResponse.status).to.equal(200); + const body = (await challengeResponse.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(10); + expect(body.complexity).to.equal(2); - const body = response.body as JsonRpcErrorResponse; - expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); - expect(body.error.message).to.equal('request payload required.'); - }); + // solve the challenge + let response = ''; + while (!checkNonce(body.challenge, response, body.complexity)) { + response = generateNonce(5); + } - it('responds with a 400 if parsing dwn request fails', async function () { - const response = await request(httpApi.api) - .post('/') - .set('dwn-request', ';;;;@!#@!$$#!@%') - .send(); + const submitResponse = await fetch('http://localhost:3000/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: body.challenge, + response: response, + did: profile.did, + }), + }); - expect(response.statusCode).to.equal(400); + expect(submitResponse.status).to.equal(200); + }); - const body = response.body as JsonRpcErrorResponse; - expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); - expect(body.error.message).to.include('JSON'); - }); + it('increase complexity as more challenges are issued', async function () { + const challengeResponse = await fetch('http://localhost:3000/register'); + expect(challengeResponse.status).to.equal(200); + const body = (await challengeResponse.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(10); + expect(body.complexity).to.equal(3); + }); - it('responds with a 2XX HTTP status if JSON RPC handler returns 4XX/5XX DWN status code', async function () { - const alice = await createProfile(); - const { recordsWrite, dataStream } = await createRecordsWriteMessage(alice); + it('rejects an invalid nonce', async function () { + const challengeResponse = await fetch('http://localhost:3000/register'); + expect(challengeResponse.status).to.equal(200); + const body = (await challengeResponse.json()) as { + challenge: string; + complexity: number; + }; + expect(body.challenge.length).to.equal(10); - // Intentionally delete a required property to produce an invalid RecordsWrite message. - const message = recordsWrite.toJSON(); - delete message['descriptor']['interface']; + // generate a nonce + let response = generateNonce(5); + // make sure the nonce is INVALID + // loop continues until checkNonce returns false, which is will probably do on the first iteration + while (checkNonce(body.challenge, response, body.complexity)) { + response = generateNonce(5); + } + + const submitResponse = await fetch('http://localhost:3000/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: body.challenge, + response: response, + did: profile.did, + }), + }); - const requestId = uuidv4(); - const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - message: message, - target: alice.did, + expect(submitResponse.status).to.equal(401); }); - const dataBytes = await DataStream.toBytes(dataStream); + it('rejects a challenge it did not issue', async function () { + const challenge = generateNonce(10); + + // solve the challenge + let response = ''; + while (!checkNonce(challenge, response, 2)) { + response = generateNonce(5); + } + + const submitResponse = await fetch('http://localhost:3000/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: challenge, + response: response, + did: profile.did, + }), + }); - // Attempt an initial RecordsWrite with the invalid message to ensure the DWN returns an error. - const responseInitialWrite = await fetch('http://localhost:3000', { - method: 'POST', - headers: { - 'dwn-request': JSON.stringify(dwnRequest), - }, - body: new Blob([dataBytes]), + expect(submitResponse.status).to.equal(401); }); - expect(responseInitialWrite.status).to.equal(200); + it('rejects unauthorized tenants', async function () { + const unauthorized = await createProfile(); + const recordsQuery = await RecordsQuery.create({ + filter: { + schema: 'woosa', + }, + signer: unauthorized.signer, + }); - const body = (await responseInitialWrite.json()) as JsonRpcResponse; - expect(body.id).to.equal(requestId); - expect(body.error).to.not.exist; + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsQuery.toJSON(), + target: unauthorized.did, + }); - const { reply } = body.result; - expect(reply.status.code).to.equal(400); - expect(reply.status.detail).to.include( - 'Both interface and method must be present', - ); + const response = await request(httpApi.api) + .post('/') + .set('dwn-request', JSON.stringify(dwnRequest)) + .send(); + + expect(response.statusCode).to.equal(403); + expect(response.body.id).to.equal(requestId); + }); }); - it('exposes dwn-response header', async function () { - // This test verifies that the Express web server includes `dwn-response` in the list of - // `access-control-expose-headers` returned in each HTTP response. This is necessary to enable applications - // that have CORS enabled to read and parse DWeb Messages that are returned as Response headers, particularly - // in the case of RecordsRead messages. + describe('/ (rpc)', function () { + it('responds with a 400 if no dwn-request header is provided', async function () { + const response = await request(httpApi.api).post('/').send(); + + expect(response.statusCode).to.equal(400); - // TODO: github.com/TBD54566975/dwn-server/issues/50 - // Consider replacing this test with a more robust method of testing, such as writing Playwright tests - // that run in a browser to verify that the `dwn-response` header can be read from the `fetch()` response - // when CORS mode is enabled. - const response = await request(httpApi.api).post('/').send(); + const body = response.body as JsonRpcErrorResponse; + expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); + expect(body.error.message).to.equal('request payload required.'); + }); - // Check if the 'access-control-expose-headers' header is present - expect(response.headers).to.have.property('access-control-expose-headers'); + it('responds with a 400 if parsing dwn request fails', async function () { + const response = await request(httpApi.api) + .post('/') + .set('dwn-request', ';;;;@!#@!$$#!@%') + .send(); - // Check if the 'dwn-response' header is listed in 'access-control-expose-headers' - const exposedHeaders = response.headers['access-control-expose-headers']; - expect(exposedHeaders).to.include('dwn-response'); - }); + expect(response.statusCode).to.equal(400); - it('works fine when no request body is provided', async function () { - const alice = await createProfile(); - const recordsQuery = await RecordsQuery.create({ - filter: { - schema: 'woosa', - }, - signer: alice.signer, + const body = response.body as JsonRpcErrorResponse; + expect(body.error.code).to.equal(JsonRpcErrorCodes.BadRequest); + expect(body.error.message).to.include('JSON'); }); - const requestId = uuidv4(); - const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - message: recordsQuery.toJSON(), - target: alice.did, + it('responds with a 2XX HTTP status if JSON RPC handler returns 4XX/5XX DWN status code', async function () { + const { recordsWrite, dataStream } = + await createRecordsWriteMessage(profile); + + // Intentionally delete a required property to produce an invalid RecordsWrite message. + const message = recordsWrite.toJSON(); + delete message['descriptor']['interface']; + + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: message, + target: profile.did, + }); + + const dataBytes = await DataStream.toBytes(dataStream); + + // Attempt an initial RecordsWrite with the invalid message to ensure the DWN returns an error. + const responseInitialWrite = await fetch('http://localhost:3000', { + method: 'POST', + headers: { + 'dwn-request': JSON.stringify(dwnRequest), + }, + body: new Blob([dataBytes]), + }); + + expect(responseInitialWrite.status).to.equal(200); + + const body = (await responseInitialWrite.json()) as JsonRpcResponse; + expect(body.id).to.equal(requestId); + expect(body.error).to.not.exist; + + const { reply } = body.result; + expect(reply.status.code).to.equal(400); + expect(reply.status.detail).to.include( + 'Both interface and method must be present', + ); + }); + + it('exposes dwn-response header', async function () { + // This test verifies that the Express web server includes `dwn-response` in the list of + // `access-control-expose-headers` returned in each HTTP response. This is necessary to enable applications + // that have CORS enabled to read and parse DWeb Messages that are returned as Response headers, particularly + // in the case of RecordsRead messages. + + // TODO: github.com/TBD54566975/dwn-server/issues/50 + // Consider replacing this test with a more robust method of testing, such as writing Playwright tests + // that run in a browser to verify that the `dwn-response` header can be read from the `fetch()` response + // when CORS mode is enabled. + const response = await request(httpApi.api).post('/').send(); + + // Check if the 'access-control-expose-headers' header is present + expect(response.headers).to.have.property( + 'access-control-expose-headers', + ); + + // Check if the 'dwn-response' header is listed in 'access-control-expose-headers' + const exposedHeaders = response.headers['access-control-expose-headers']; + expect(exposedHeaders).to.include('dwn-response'); }); - const response = await request(httpApi.api) - .post('/') - .set('dwn-request', JSON.stringify(dwnRequest)) - .send(); + it('works fine when no request body is provided', async function () { + const recordsQuery = await RecordsQuery.create({ + filter: { + schema: 'woosa', + }, + signer: profile.signer, + }); - expect(response.statusCode).to.equal(200); - expect(response.body.id).to.equal(requestId); - expect(response.body.error).to.not.exist; - expect(response.body.result.reply.status.code).to.equal(200); + const requestId = uuidv4(); + const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { + message: recordsQuery.toJSON(), + target: profile.did, + }); + + const response = await request(httpApi.api) + .post('/') + .set('dwn-request', JSON.stringify(dwnRequest)) + .send(); + + expect(response.statusCode).to.equal(200); + expect(response.body.id).to.equal(requestId); + expect(response.body.error).to.not.exist; + expect(response.body.result.reply.status.code).to.equal(200); + }); }); describe('RecordsWrite', function () { @@ -168,8 +304,7 @@ describe('http api', function () { const filePath = './fixtures/test.jpeg'; const { cid, size, stream } = await getFileAsReadStream(filePath); - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice, { + const { recordsWrite } = await createRecordsWriteMessage(profile, { dataCid: cid, dataSize: size, }); @@ -177,7 +312,7 @@ describe('http api', function () { const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: recordsWrite.toJSON(), - target: alice.did, + target: profile.did, }); const resp = await streamHttpRequest( @@ -203,16 +338,14 @@ describe('http api', function () { }); it('handles RecordsWrite overwrite that does not mutate data', async function () { - const alice = await createProfile(); - // First RecordsWrite that creates the record. const { recordsWrite: initialWrite, dataStream } = - await createRecordsWriteMessage(alice); + await createRecordsWriteMessage(profile); const dataBytes = await DataStream.toBytes(dataStream); let requestId = uuidv4(); let dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: initialWrite.toJSON(), - target: alice.did, + target: profile.did, }); const responseInitialWrite = await fetch('http://localhost:3000', { @@ -227,7 +360,7 @@ describe('http api', function () { // Subsequent RecordsWrite that mutates the published property of the record. const { recordsWrite: overWrite } = await createRecordsWriteMessage( - alice, + profile, { recordId: initialWrite.message.recordId, dataCid: initialWrite.message.descriptor.dataCid, @@ -240,7 +373,7 @@ describe('http api', function () { requestId = uuidv4(); dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: overWrite.toJSON(), - target: alice.did, + target: profile.did, }); const responseOverwrite = await fetch('http://localhost:3000', { method: 'POST', @@ -261,14 +394,13 @@ describe('http api', function () { }); it('handles a RecordsWrite tombstone', async function () { - const alice = await createProfile(); const { recordsWrite: tombstone } = - await createRecordsWriteMessage(alice); + await createRecordsWriteMessage(profile); const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: tombstone.toJSON(), - target: alice.did, + target: profile.did, }); const responeTombstone = await fetch('http://localhost:3000', { @@ -309,8 +441,7 @@ describe('http api', function () { stream, } = await getFileAsReadStream(filePath); - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice, { + const { recordsWrite } = await createRecordsWriteMessage(profile, { dataCid: expectedCid, dataSize: size, }); @@ -318,7 +449,7 @@ describe('http api', function () { let requestId = uuidv4(); let dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: recordsWrite.toJSON(), - target: alice.did, + target: profile.did, }); let response = await fetch('http://localhost:3000', { @@ -339,7 +470,7 @@ describe('http api', function () { expect(reply.status.code).to.equal(202); const recordsRead = await RecordsRead.create({ - signer: alice.signer, + signer: profile.signer, filter: { recordId: recordsWrite.message.recordId, }, @@ -347,7 +478,7 @@ describe('http api', function () { requestId = uuidv4(); dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { - target: alice.did, + target: profile.did, message: recordsRead.toJSON(), }); @@ -393,8 +524,7 @@ describe('http api', function () { stream, } = await getFileAsReadStream(filePath); - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice, { + const { recordsWrite } = await createRecordsWriteMessage(profile, { dataCid: expectedCid, dataSize: size, published: true, @@ -403,7 +533,7 @@ describe('http api', function () { const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: recordsWrite.toJSON(), - target: alice.did, + target: profile.did, }); let response = await fetch('http://localhost:3000', { @@ -424,7 +554,7 @@ describe('http api', function () { expect(reply.status.code).to.equal(202); response = await fetch( - `http://localhost:3000/${alice.did}/records/${recordsWrite.message.recordId}`, + `http://localhost:3000/${profile.did}/records/${recordsWrite.message.recordId}`, ); const blob = await response.blob(); @@ -439,8 +569,7 @@ describe('http api', function () { stream, } = await getFileAsReadStream(filePath); - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice, { + const { recordsWrite } = await createRecordsWriteMessage(profile, { dataCid: expectedCid, dataSize: size, }); @@ -448,7 +577,7 @@ describe('http api', function () { const requestId = uuidv4(); const dwnRequest = createJsonRpcRequest(requestId, 'dwn.processMessage', { message: recordsWrite.toJSON(), - target: alice.did, + target: profile.did, }); let response = await fetch('http://localhost:3000', { @@ -469,25 +598,23 @@ describe('http api', function () { expect(reply.status.code).to.equal(202); response = await fetch( - `http://localhost:3000/${alice.did}/records/${recordsWrite.message.recordId}`, + `http://localhost:3000/${profile.did}/records/${recordsWrite.message.recordId}`, ); expect(response.status).to.equal(404); }); it('returns a 404 if record doesnt exist', async function () { - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice); + const { recordsWrite } = await createRecordsWriteMessage(profile); const response = await fetch( - `http://localhost:3000/${alice.did}/records/${recordsWrite.message.recordId}`, + `http://localhost:3000/${profile.did}/records/${recordsWrite.message.recordId}`, ); expect(response.status).to.equal(404); }); it('returns a 404 for invalid did', async function () { - const alice = await createProfile(); - const { recordsWrite } = await createRecordsWriteMessage(alice); + const { recordsWrite } = await createRecordsWriteMessage(profile); const response = await fetch( `http://localhost:3000/1234567892345678/records/${recordsWrite.message.recordId}`, @@ -496,94 +623,12 @@ describe('http api', function () { }); it('returns a 404 for invalid record id', async function () { - const alice = await createProfile(); const response = await fetch( - `http://localhost:3000/${alice.did}/records/kaka`, + `http://localhost:3000/${profile.did}/records/kaka`, ); expect(response.status).to.equal(404); }); }); - - describe('/register', function () { - it('returns a register challenge', async function () { - const response = await fetch('http://localhost:3000/register'); - expect(response.status).to.equal(200); - const body = (await response.json()) as { - challenge: string; - complexity: number; - }; - expect(body.challenge.length).to.equal(10); - expect(body.complexity).to.equal(1); - }); - - it('accepts a correct registration challenge', async function () { - const challengeResponse = await fetch('http://localhost:3000/register'); - expect(challengeResponse.status).to.equal(200); - const body = (await challengeResponse.json()) as { - challenge: string; - complexity: number; - }; - expect(body.challenge.length).to.equal(10); - expect(body.complexity).to.equal(2); - - // solve the challenge - let response = ''; - while (!checkNonce(body.challenge, response, body.complexity)) { - response = generateNonce(5); - } - - const submitResponse = await fetch('http://localhost:3000/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - challenge: body.challenge, - response: response, - did: 'aaa', - }), - }); - - expect(submitResponse.status).to.equal(200); - }); - - it('increase complexity as more challenges are issued', async function () { - const challengeResponse = await fetch('http://localhost:3000/register'); - expect(challengeResponse.status).to.equal(200); - const body = (await challengeResponse.json()) as { - challenge: string; - complexity: number; - }; - expect(body.challenge.length).to.equal(10); - expect(body.complexity).to.equal(3); - }); - - it('rejects an invalid challenge', async function () { - const challengeResponse = await fetch('http://localhost:3000/register'); - expect(challengeResponse.status).to.equal(200); - const body = (await challengeResponse.json()) as { - challenge: string; - complexity: number; - }; - expect(body.challenge.length).to.equal(10); - - // solve the challenge - let response = generateNonce(5); - while (checkNonce(body.challenge, response, body.complexity)) { - response = generateNonce(5); - } - - const submitResponse = await fetch('http://localhost:3000/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - challenge: body.challenge, - response: response, - did: 'aaa', - }), - }); - - expect(submitResponse.status).to.equal(401); - }); - }); }); const nonceChars =