Skip to content

Commit

Permalink
Added Web5 Connect Server (#139)
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
thehenrytsai authored Jun 28, 2024
1 parent 22886cb commit 6471773
Show file tree
Hide file tree
Showing 6 changed files with 316 additions and 6 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
7 changes: 7 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
102 changes: 96 additions & 6 deletions src/http-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,28 @@ 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 {
#config: DwnServerConfig;
#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,
Expand All @@ -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();
}
Expand Down Expand Up @@ -313,6 +320,7 @@ export class HttpApi {
}

res.json({
url : config.baseUrl,
server : this.#packageInfo.server,
maxFileSize : config.maxRecordDataSize,
registrationRequirements : registrationRequirements,
Expand All @@ -321,6 +329,8 @@ export class HttpApi {
webSocketSupport : config.webSocketSupport,
});
});

this.#setupWeb5ConnectServerRoutes();
}

#listen(port: number, callback?: () => void): void {
Expand All @@ -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);
Expand All @@ -353,14 +363,94 @@ 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 });
}
}
});
}
}

#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<http.Server> {
this.#listen(port, callback);
return this.#server;
Expand Down
95 changes: 95 additions & 0 deletions src/web5-connect/web5-connect-server.ts
Original file line number Diff line number Diff line change
@@ -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<SetWeb5ConnectRequestResult> {
// 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<Web5ConnectRequest | undefined> {
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<any> {
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<Web5ConnectResponse | undefined> {
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;
}
}
1 change: 1 addition & 0 deletions tests/http-api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading

0 comments on commit 6471773

Please sign in to comment.