From 6471773c35e3de688cb1674692080829806240ef Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Fri, 28 Jun 2024 13:44:58 -0700 Subject: [PATCH] Added Web5 Connect Server (#139) - Ported web5 connect server into this repo. - Separated business logic from `express` into a `Web5ConnectServer` class. - Added tests for 100% new code coverage. - Added issue to track TODOs. --- README.md | 1 + src/config.ts | 7 ++ src/http-api.ts | 102 +++++++++++++++++++-- src/web5-connect/web5-connect-server.ts | 95 +++++++++++++++++++ tests/http-api.spec.ts | 1 + tests/scenarios/web5-connect.spec.ts | 116 ++++++++++++++++++++++++ 6 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 src/web5-connect/web5-connect-server.ts create mode 100644 tests/scenarios/web5-connect.spec.ts diff --git a/README.md b/README.md index cf5eca0..e8a377e 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,7 @@ Configuration can be set using environment variables | `DS_PORT` | Port that the server listens on | `3000` | | `DS_MAX_RECORD_DATA_SIZE` | Maximum size for `RecordsWrite` data. use `b`, `kb`, `mb`, `gb` for value | `1gb` | | `DS_WEBSOCKET_SERVER` | Whether to enable listening over `ws:`. values: `on`,`off` | `on` | +| `DWN_BASE_URL` | Base external URL of this DWN. Used to construct URL paths such as the `Request URI` for the Web5 Connect flow. | `http://localhost` | | `DWN_REGISTRATION_STORE_URL` | URL to use for storage of registered DIDs. Leave unset to if DWN does not require registration (ie. open for all) | unset | | `DWN_REGISTRATION_PROOF_OF_WORK_SEED` | Seed to generate the challenge nonce from, this allows all DWN instances in a cluster to generate the same challenge. | unset | | `DWN_REGISTRATION_PROOF_OF_WORK_ENABLED` | Require new users to complete a proof-of-work challenge | `false` | diff --git a/src/config.ts b/src/config.ts index ee1f895..320c5e1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,13 @@ export const config = { * otherwise we fall back on the use defined `DWN_SERVER_PACKAGE_NAME` or `@web5/dwn-server`. */ serverName: process.env.npm_package_name || process.env.DWN_SERVER_PACKAGE_NAME || '@web5/dwn-server', + + /** + * The base external URL of this DWN. + * This is used to construct URL paths such as the `Request URI` in the Web5 Connect flow. + */ + baseUrl: process.env.DWN_BASE_URL || 'http://localhost', + /** * Used to populate the `version` and `sdkVersion` properties returned by the `/info` endpoint. * diff --git a/src/http-api.ts b/src/http-api.ts index 4f9c3b3..9a4a533 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -12,14 +12,15 @@ import { v4 as uuidv4 } from 'uuid'; import type { RequestContext } from './lib/json-rpc-router.js'; import type { JsonRpcRequest } from './lib/json-rpc.js'; -import { createJsonRpcErrorResponse, JsonRpcErrorCodes } from './lib/json-rpc.js'; import type { DwnServerConfig } from './config.js'; +import type { DwnServerError } from './dwn-error.js'; +import type { RegistrationManager } from './registration/registration-manager.js'; import { config } from './config.js'; -import { type DwnServerError } from './dwn-error.js'; import { jsonRpcRouter } from './json-rpc-api.js'; +import { Web5ConnectServer } from './web5-connect/web5-connect-server.js'; +import { createJsonRpcErrorResponse, JsonRpcErrorCodes } from './lib/json-rpc.js'; import { requestCounter, responseHistogram } from './metrics.js'; -import type { RegistrationManager } from './registration/registration-manager.js'; export class HttpApi { @@ -27,11 +28,12 @@ export class HttpApi { #packageInfo: { version?: string, sdkVersion?: string, server: string }; #api: Express; #server: http.Server; + web5ConnectServer: Web5ConnectServer; registrationManager: RegistrationManager; dwn: Dwn; constructor(config: DwnServerConfig, dwn: Dwn, registrationManager?: RegistrationManager) { - console.log(config); + log.info(config); this.#packageInfo = { server: config.serverName, @@ -55,6 +57,11 @@ export class HttpApi { this.registrationManager = registrationManager; } + // create the Web5 Connect Server + this.web5ConnectServer = new Web5ConnectServer({ + baseUrl: `${config.baseUrl}:${config.port}`, + }); + this.#setupMiddleware(); this.#setupRoutes(); } @@ -313,6 +320,7 @@ export class HttpApi { } res.json({ + url : config.baseUrl, server : this.#packageInfo.server, maxFileSize : config.maxRecordDataSize, registrationRequirements : registrationRequirements, @@ -321,6 +329,8 @@ export class HttpApi { webSocketSupport : config.webSocketSupport, }); }); + + this.#setupWeb5ConnectServerRoutes(); } #listen(port: number, callback?: () => void): void { @@ -342,7 +352,7 @@ export class HttpApi { if (this.#config.registrationStoreUrl !== undefined) { this.#api.post('/registration', async (req: Request, res: Response) => { const requestBody = req.body; - console.log('Registration request:', requestBody); + log.info('Registration request:', requestBody); try { await this.registrationManager.handleRegistrationRequest(requestBody); @@ -353,7 +363,7 @@ export class HttpApi { if (dwnServerError.code !== undefined) { res.status(400).json(dwnServerError); } else { - console.log('Error handling registration request:', error); + log.info('Error handling registration request:', error); res.status(500).json({ success: false }); } } @@ -361,6 +371,86 @@ export class HttpApi { } } + #setupWeb5ConnectServerRoutes(): void { + /** + * Endpoint that the connecting App pushes the Pushed Authorization Request Object to start the Web5 Connect flow. + */ + this.#api.post('/connect/par', async (req, res) => { + log.info('Storing Pushed Authorization Request (PAR) request...'); + + const result = await this.web5ConnectServer.setWeb5ConnectRequest(req.body.request); + res.status(201).json(result); + }); + + /** + * Endpoint that the Identity Provider (wallet) calls to retrieve the Pushed Authorization Request. + */ + this.#api.get('/connect/:requestId.jwt', async (req, res) => { + log.info(`Retrieving Web5 Connect Request object of ID: ${req.params.requestId}...`); + + // Look up the request object based on the requestId. + const requestObjectJwt = await this.web5ConnectServer.getWeb5ConnectRequest(req.params.requestId); + + if (!requestObjectJwt) { + res.status(404).json({ + ok : false, + status : { code: 404, message: 'Not Found' } + }); + } else { + res.set('Content-Type', 'application/jwt'); + res.send(requestObjectJwt); + } + }); + + /** + * Endpoint that the Identity Provider (wallet) pushes the Authorization Response ID token to. + */ + this.#api.post('/connect/sessions', async (req, res) => { + log.info('Storing Identity Provider (wallet) pushed response with ID token...'); + + // Store the ID token. + const idToken = req.body.id_token; + const state = req.body.state; + + if (idToken !== undefined && state != undefined) { + + await this.web5ConnectServer.setWeb5ConnectResponse(state, idToken); + + res.status(201).json({ + ok : true, + status : { code: 201, message: 'Created' } + }); + + } else { + res.status(400).json({ + ok : false, + status : { code: 400, message: 'Bad Request' } + }); + } + }); + + /** + * Endpoint that the connecting App polls to check if the Identity Provider (Wallet) has posted the Web5 Connect Response object. + * The Web5 Connect Response is also an ID token. + */ + this.#api.get('/connect/sessions/:state.jwt', async (req, res) => { + log.info(`Retrieving ID token for state: ${req.params.state}...`); + + // Look up the ID token. + const idToken = await this.web5ConnectServer.getWeb5ConnectResponse(req.params.state); + + if (!idToken) { + res.status(404).json({ + ok : false, + status : { code: 404, message: 'Not Found' } + }); + } else { + res.set('Content-Type', 'application/jwt'); + res.send(idToken); + } + }); + } + async start(port: number, callback?: () => void): Promise { this.#listen(port, callback); return this.#server; diff --git a/src/web5-connect/web5-connect-server.ts b/src/web5-connect/web5-connect-server.ts new file mode 100644 index 0000000..a0b7405 --- /dev/null +++ b/src/web5-connect/web5-connect-server.ts @@ -0,0 +1,95 @@ +import { randomUuid } from '@web5/crypto/utils'; + +/** + * The Web5 Connect Request object. + */ +export type Web5ConnectRequest = any; // TODO: define type in common repo for reuse (https://github.com/TBD54566975/dwn-server/issues/138) + +/** + * The Web5 Connect Response object, which is also an OIDC ID token + */ +export type Web5ConnectResponse = any; // TODO: define type in common repo for reuse (https://github.com/TBD54566975/dwn-server/issues/138) + +/** + * The result of the setWeb5ConnectRequest() method. + */ +export type SetWeb5ConnectRequestResult = { + /** + * The Request URI that the wallet should use to retrieve the request object. + */ + request_uri: string; + + /** + * The time in seconds that the Request URI is valid for. + */ + expires_in: number; +} + +/** + * The Web5 Connect Server is responsible for handling the Web5 Connect flow. + */ +export class Web5ConnectServer { + + private baseUrl: string; + private dataStore = new Map(); // TODO: turn this into a TTL cache (https://github.com/TBD54566975/dwn-server/issues/138) + + /** + * Creates a new instance of the Web5 Connect Server. + * @param params.baseUrl The the base URL of the connect server including the port. + * This is given to the Identity Provider (wallet) to fetch the Web5 Connect Request object. + */ + public constructor({ baseUrl }: { + baseUrl: string; + }) { + this.baseUrl = baseUrl; + } + + /** + * Stores the given Web5 Connect Request object, which is also an OAuth 2 Pushed Authorization Request (PAR) object. + * This is the initial call to the connect server to start the Web5 Connect flow. + */ + public async setWeb5ConnectRequest(request: Web5ConnectRequest): Promise { + // Generate a request URI + const requestId = randomUuid(); + const request_uri = `${this.baseUrl}/connect/${requestId}.jwt`; + + // Store the Request Object. + this.dataStore.set(`request:${requestId}`, request); + + return { + request_uri, + expires_in : 600, + }; + } + + /** + * Returns the Web5 Connect Request object. The request ID can only be used once. + */ + public async getWeb5ConnectRequest(requestId: string): Promise { + const request = this.dataStore.get(`request:${requestId}`); + + // Delete the Request Object from the data store now that it has been retrieved. + this.dataStore.delete(`request:${requestId}`); + + return request; + } + + /** + * Sets the Web5 Connect Response object, which is also an OIDC ID token. + */ + public async setWeb5ConnectResponse(state: string, response: Web5ConnectResponse): Promise { + this.dataStore.set(`response:${state}`, response); + } + + /** + * Gets the Web5 Connect Response object. The `state` string can only be used once. + */ + public async getWeb5ConnectResponse(state: string): Promise { + const response = this. dataStore.get(`response:${state}`); + + // Delete the Response object from the data store now that it has been retrieved. + this.dataStore.delete(`response:${state}`); + + return response; + } +} \ No newline at end of file diff --git a/tests/http-api.spec.ts b/tests/http-api.spec.ts index 5d59638..164ec06 100644 --- a/tests/http-api.spec.ts +++ b/tests/http-api.spec.ts @@ -1042,6 +1042,7 @@ describe('http api', function () { expect(resp.status).to.equal(200); const info = await resp.json(); + expect(info['url']).to.equal('http://localhost'); expect(info['server']).to.equal('@web5/dwn-server'); expect(info['registrationRequirements']).to.include('terms-of-service'); expect(info['registrationRequirements']).to.include( diff --git a/tests/scenarios/web5-connect.spec.ts b/tests/scenarios/web5-connect.spec.ts new file mode 100644 index 0000000..54da59b --- /dev/null +++ b/tests/scenarios/web5-connect.spec.ts @@ -0,0 +1,116 @@ +import fetch from 'node-fetch'; +import { config } from '../../src/config.js'; +import { DwnServer } from '../../src/dwn-server.js'; +import { expect } from 'chai'; +import { webcrypto } from 'node:crypto'; + +// node.js 18 and earlier needs globalThis.crypto polyfill +if (!globalThis.crypto) { + // @ts-ignore + globalThis.crypto = webcrypto; +} + +describe('Web5 Connect scenarios', function () { + const web5ConnectBaseUrl = 'http://localhost:3000'; + + let dwnServer: DwnServer; + const dwnServerConfig = { ...config } // not touching the original config + + before(async function () { + + // NOTE: using SQL to workaround an issue where multiple instances of DwnServer can be started using LevelDB in the same test run, + // and dwn-server.spec.ts already uses LevelDB. + dwnServerConfig.messageStore = 'sqlite://', + dwnServerConfig.dataStore = 'sqlite://', + dwnServerConfig.eventLog = 'sqlite://', + + dwnServer = new DwnServer({ config: dwnServerConfig }); + await dwnServer.start(); + }); + + after(function () { + dwnServer.stop(() => { }); + }); + + beforeEach(function () { + dwnServer.start(); + }); + + afterEach(function () { + dwnServer.stop(() => {}); + }); + + it('should be able to set and get Web5 Connect Request & Response objects', async () => { + // Scenario: + // 1. App sends the Web5 Connect Request object to the Web5 Connect server. + // 2. Identity Provider (wallet) fetches the Web5 Connect Request object from the Web5 Connect server. + // 3. Should receive 404 if fetching the same Web5 Connect Request again + // 4. Identity Provider (wallet) should receive 400 if sending an incomplete response. + // 5. Identity Provider (wallet) sends the Web5 Connect Response object to the Web5 Connect server. + // 6. App fetches the Web5 Connect Response object from the Web5 Connect server. + // 7. Should receive 404 if fetching the same Web5 Connect Response object again. + + // 1. App sends the Web5 Connect Request object to the Web5 Connect server. + const requestBody = { request: { dummyProperty: 'dummyValue' } }; + const postWeb5ConnectRequestResult = await fetch(`${web5ConnectBaseUrl}/connect/par`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }); + expect(postWeb5ConnectRequestResult.status).to.equal(201); + + // 2. Identity Provider (wallet) fetches the Web5 Connect Request object from the Web5 Connect server. + const requestUrl = (await postWeb5ConnectRequestResult.json() as any).request_uri; + const getWeb5ConnectRequestResult = await fetch(requestUrl, { + method: 'GET', + }); + const fetchedRequest = await getWeb5ConnectRequestResult.json(); + expect(getWeb5ConnectRequestResult.status).to.equal(200); + expect(fetchedRequest).to.deep.equal(requestBody.request); + + // 3. Should receive 404 if fetching the same Web5 Connect Request again + const getWeb5ConnectRequestResult2 = await fetch(requestUrl, { + method: 'GET', + }); + expect(getWeb5ConnectRequestResult2.status).to.equal(404); + + // 4. Identity Provider (wallet) should receive 400 if sending an incomplete response. + const incompleteResponseBody = { + id_token : { dummyToken: 'dummyToken' }, + // state : 'dummyState', // intentionally missing + }; + const postIncompleteWeb5ConnectResponseResult = await fetch(`${web5ConnectBaseUrl}/connect/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(incompleteResponseBody), + }); + expect(postIncompleteWeb5ConnectResponseResult.status).to.equal(400); + + // 5. Identity Provider (wallet) sends the Web5 Connect Response object to the Web5 Connect server. + const web5ConnectResponseBody = { + id_token : { dummyToken: 'dummyToken' }, + state : 'dummyState', + }; + const postWeb5ConnectResponseResult = await fetch(`${web5ConnectBaseUrl}/connect/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(web5ConnectResponseBody), + }); + expect(postWeb5ConnectResponseResult.status).to.equal(201); + + // 6. App fetches the Web5 Connect Response object from the Web5 Connect server. + const web5ConnectResponseUrl = `${web5ConnectBaseUrl}/connect/sessions/${web5ConnectResponseBody.state}.jwt`; + const getWeb5ConnectResponseResult = await fetch(web5ConnectResponseUrl, { + method: 'GET', + }); + const fetchedResponse = await getWeb5ConnectResponseResult.json(); + expect(getWeb5ConnectResponseResult.status).to.equal(200); + expect(fetchedResponse).to.deep.equal(web5ConnectResponseBody.id_token); + + // 7. Should receive 404 if fetching the same Web5 Connect Response object again. + const getWeb5ConnectResponseResult2 = await fetch(web5ConnectResponseUrl, { + method: 'GET', + }); + expect(getWeb5ConnectResponseResult2.status).to.equal(404); + }); +});