Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Web5 Connect Server #139

Merged
merged 4 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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...');
LiranCohen marked this conversation as resolved.
Show resolved Hide resolved

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) => {
LiranCohen marked this conversation as resolved.
Show resolved Hide resolved
console.log(`Retrieving Web5 Connect Request object of ID: ${req.params.requestId}...`);
LiranCohen marked this conversation as resolved.
Show resolved Hide resolved

// 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...');
LiranCohen marked this conversation as resolved.
Show resolved Hide resolved

// 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}...`);
LiranCohen marked this conversation as resolved.
Show resolved Hide resolved

// 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.');
LiranCohen marked this conversation as resolved.
Show resolved Hide resolved

// 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);
});
});
Loading