Skip to content

Commit

Permalink
Added Web5 Connect Server
Browse files Browse the repository at this point in the history
  • Loading branch information
thehenrytsai committed Jun 27, 2024
1 parent 22886cb commit 662b234
Show file tree
Hide file tree
Showing 4 changed files with 312 additions and 3 deletions.
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,10 @@ export const config = {

// log level - trace/debug/info/warn/error
logLevel: process.env.DWN_SERVER_LOG_LEVEL || 'INFO',

/**
* The base URL of the connect server excluding the port (port will be appended by using the `port` param in this config),
* this is used to construct the full Web5 Connect Request URI for the Identity Provider (wallet) to use to fetch the Web5 Connect Request object.
*/
web5ConnectServerBaseUrl: process.env.WEB5_CONNECT_SERVER_BASE_URL || 'http://localhost',
};
95 changes: 92 additions & 3 deletions src/http-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,23 @@ 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;

Expand Down Expand Up @@ -55,6 +57,11 @@ export class HttpApi {
this.registrationManager = registrationManager;
}

// setup the Web5 Connect Server
this.web5ConnectServer = new Web5ConnectServer({
baseUrl: `${config.web5ConnectServerBaseUrl}:${config.port}`,
});

this.#setupMiddleware();
this.#setupRoutes();
}
Expand Down Expand Up @@ -321,6 +328,8 @@ export class HttpApi {
webSocketSupport : config.webSocketSupport,
});
});

this.#setupWeb5ConnectServerRoutes();
}

#listen(port: number, callback?: () => void): void {
Expand Down Expand Up @@ -361,6 +370,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) => {
console.log('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) => {
console.log(`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) => {
console.log('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) => {
console.log(`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
98 changes: 98 additions & 0 deletions src/web5-connect/web5-connect-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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 used to construct the full request URI.
*/
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> {
console.log('Received Pushed Authorization Request (PAR) request.');

// 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> {
console.log('Identity Provider pushed response with ID token.');

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;
}
}
116 changes: 116 additions & 0 deletions tests/scenarios/web5-connect.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});

0 comments on commit 662b234

Please sign in to comment.