diff --git a/app/scripts/preBuild.tsx b/app/scripts/preBuild.tsx index dc18ca8623..a73c7e4504 100644 --- a/app/scripts/preBuild.tsx +++ b/app/scripts/preBuild.tsx @@ -4,70 +4,7 @@ dotenv.config(); import { writeFileSync } from "fs"; import { join } from "path"; -import { PlatformGroupSpec, platforms } from "@gitcoin/passport-platforms"; - -type StampData = { - name: string; - description: string; - hash: string; -}; - -type GroupData = { - name: string; - stamps: StampData[]; -}; - -type PlatformData = { - name: string; - icon: string; - description: string; - connectMessage: string; - groups: GroupData[]; -}; - -const skipPlatforms = ["ClearText"]; - -const formatPlatformGroups = (providerConfig: PlatformGroupSpec[]) => - providerConfig.reduce( - (groups: GroupData[], group: PlatformGroupSpec) => [ - ...groups, - { - name: group.platformGroup, - stamps: group.providers.map(({ name, title, hash }) => { - if (!hash) { - throw new Error(`No hash defined for ${name}`); - } - return { - name, - hash, - description: title, - }; - }), - }, - ], - [] as GroupData[] - ); - -const platformsData = Object.entries(platforms).reduce((data, [id, platform]) => { - if (skipPlatforms.includes(id)) return data; - - const { name, icon, description, connectMessage } = platform.PlatformDetails; - if (!icon) throw new Error(`No icon defined for ${id}`); - - const groups = formatPlatformGroups(platform.ProviderConfig); - - return [ - ...data, - { - id, - name, - icon, - description, - connectMessage, - groups, - }, - ]; -}, [] as PlatformData[]); +import { platformsData } from "@gitcoin/passport-platforms"; const outPath = join(__dirname, "..", "public", "stampMetadata.json"); console.log(`Saving platform info to JSON file at ${outPath}`); diff --git a/embed/.env-example.env b/embed/.env-example.env index e0788ff927..43d991eb17 100644 --- a/embed/.env-example.env +++ b/embed/.env-example.env @@ -84,3 +84,4 @@ ALCHEMY_API_KEY=... SCROLL_BADGE_PROVIDER_INFO='{"badge_provider":{"contractAddress":"0x...","level":1}}' SCROLL_BADGE_ATTESTATION_SCHEMA_UID=0xd57de4f41c3d3cc855eadef68f98c0d4edd22d57161d96b7c06d2f4336cc3b49 +REDIS_URL=redis://localhost:6379/0 diff --git a/embed/README.md b/embed/README.md index 2d6520cc94..37ffaf6580 100644 --- a/embed/README.md +++ b/embed/README.md @@ -2,7 +2,6 @@ The Passport Embed service implements the backend part used by the passport embed UI component. - ``` # Ensure you copy and update the required variables for the environment $ cp ./.env-example.env ./.env @@ -40,3 +39,11 @@ There are a few options for adding the variable into the build process: Passport uses redis to handle caching. For local development you can spin up a redis instance using docker: `docker run -d -p 6379:6379 redis` or using whatever other method you prefer. The redis instance should be available at `localhost:6379` by default. + +## Example requests + +```bash +curl -X POST http://localhost:80/embed/verify \ + -H "Content-Type: application/json" \ + -d '{"address":"0x85fF01cfF157199527528788ec4eA6336615C989", "scorerId":736}' +``` diff --git a/embed/__mocks__/ioredis.js b/embed/__mocks__/ioredis.js new file mode 100644 index 0000000000..4caf39e3b9 --- /dev/null +++ b/embed/__mocks__/ioredis.js @@ -0,0 +1,18 @@ +// __mocks__/ioredis.ts +const RedisMock = jest.fn().mockImplementation(() => { + return { + get: jest.fn((key) => Promise.resolve(null)), + set: jest.fn((key, value) => { + return Promise.resolve("OK"); + }), + on: jest.fn((key, func) => {}), + call: jest.fn((type, ...args) => { + if(type === "EVALSHA") { + return Promise.resolve([ 1, 60000 ]); + } + return Promise.resolve("OK"); + }), + }; +}); + +module.exports = RedisMock; diff --git a/embed/__tests__/autoVerification.test.ts b/embed/__tests__/autoVerification.test.ts new file mode 100644 index 0000000000..fe10cb5ec2 --- /dev/null +++ b/embed/__tests__/autoVerification.test.ts @@ -0,0 +1,442 @@ +import { Request, Response } from "express"; +import axios, { AxiosError } from "axios"; +import { autoVerificationHandler, PassportScore } from "../src/autoVerification"; +import { + PROVIDER_ID, + VerifiableCredential, + RequestPayload, + ProviderContext, + IssuedCredential, +} from "@gitcoin/passport-types"; +import { providers } from "@gitcoin/passport-platforms"; +import { issueHashedCredential } from "@gitcoin/passport-identity"; + +// Mock all external dependencies +jest.mock("ethers", () => { + const originalModule = jest.requireActual("ethers"); + return { + ...originalModule, + isAddress: jest.fn(() => { + return true; + }), + }; +}); + +jest.mock("axios"); + +jest.mock("@gitcoin/passport-identity"); + +const expectedEvmProvidersToSucceed = new Set([ + "ETHDaysActive#50", + "ETHGasSpent#0.25", + "ETHScore#50", + "ETHScore#75", +]); + +const expectedEvmProvidersToFail = new Set(["ETHScore#90", "HolonymGovIdProvider", "HolonymPhone"]); + +const createMockVerifiableCredential = (provider: string, address: string): VerifiableCredential => ({ + "@context": ["https://www.w3.org/2018/credentials/v1", "https://w3id.org/security/suites/eip712sig-2021/v1"], + type: ["VerifiableCredential", "EVMCredential"], + credentialSubject: { + id: `did:pkh:eip155:1:${address}`, + "@context": { + hash: "https://schema.org/Text", + provider: "https://schema.org/Text", + address: "https://schema.org/Text", + challenge: "https://schema.org/Text", + metaPointer: "https://schema.org/URL", + }, + hash: "0x123456789", + provider: provider, + address: address, + challenge: "test-challenge", + metaPointer: "https://example.com/metadata", + }, + issuer: "did:key:test-issuer", + issuanceDate: new Date().toISOString(), + expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days from now + proof: { + "@context": "https://w3id.org/security/suites/eip712sig-2021/v1", + type: "EthereumEip712Signature2021", + proofPurpose: "assertionMethod", + proofValue: "0xabcdef1234567890", + verificationMethod: "did:key:test-verification", + created: new Date().toISOString(), + eip712Domain: { + domain: { + name: "GitcoinVerifiableCredential", + }, + primaryType: "VerifiableCredential", + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + ], + VerifiableCredential: [ + { name: "id", type: "string" }, + { name: "address", type: "string" }, + ], + }, + }, + }, +}); + +function getMockedIssuedCredential(provider: string, address: string): IssuedCredential { + const credential: IssuedCredential = { + credential: createMockVerifiableCredential(provider, address), + }; + return credential; +} + +jest.mock("@gitcoin/passport-platforms", () => { + const originalModule = + jest.requireActual("@gitcoin/passport-platforms"); + return { + ...originalModule, + providers: { + verify: jest.fn(async (type: string, payload: RequestPayload, context: ProviderContext) => { + console.log("?????????"); + return Promise.resolve({ + valid: true, + record: { key: "veirfied-condition" }, + }); + }), + }, + platforms: { + "provider-ok-1": { + PlatformDetails: { + platform: "ETH", + name: "test 1", + description: "test 1", + connectMessage: "test 1", + isEVM: true, + }, + ProviderConfig: [ + { + platformGroup: "Group 1", + providers: [ + { + name: "ETHDaysActive#50", + title: "test-1", + }, + { + name: "ETHGasSpent#0.25", + title: "test-1", + }, + ], + }, + { + platformGroup: "Group 2", + providers: [ + { + name: "ETHScore#50", + title: "test-1", + }, + { + name: "ETHScore#75", + title: "test-1", + }, + { + name: "ETHScore#90", + title: "test-1", + }, + ], + }, + ], + }, + "provider-ok-2": { + PlatformDetails: { + platform: "Holonym", + name: "test 1", + description: "test 1", + connectMessage: "test 1", + isEVM: true, + }, + ProviderConfig: [ + { + platformGroup: "Group 1", + providers: [ + { + name: "HolonymGovIdProvider", + title: "test-1", + }, + { + name: "HolonymPhone", + title: "test-1", + }, + ], + }, + ], + }, + "provider-bad-1": { + PlatformDetails: { + platform: "Facebook", + name: "test 1", + description: "test 1", + connectMessage: "test 1", + isEVM: false, + }, + ProviderConfig: [ + { + platformGroup: "Group 1", + providers: [ + { + name: "Facebook", + title: "test-1", + }, + { + name: "FacebookProfilePicture", + title: "test-1", + }, + ], + }, + ], + }, + "provider-bad-2": { + PlatformDetails: { + platform: "Github", + name: "test 1", + description: "test 1", + connectMessage: "test 1", + isEVM: false, + }, + ProviderConfig: [ + { + platformGroup: "Group 1", + providers: [ + { + name: "githubContributionActivityGte#120", + title: "test-1", + }, + { + name: "githubContributionActivityGte#30", + title: "test-1", + }, + ], + }, + { + platformGroup: "Group 2", + providers: [ + { + name: "githubContributionActivityGte#60", + title: "test-1", + }, + ], + }, + ], + }, + }, + }; +}); + +const mockedScore: PassportScore = { + address: "0x0000000000000000000000000000000000000000", + score: "12", + passing_score: true, + last_score_timestamp: new Date().toISOString(), + expiration_timestamp: new Date().toISOString(), + threshold: "20.000", + error: "", + stamps: { "provider-1": { score: "12", dedup: true, expiration_date: new Date().toISOString() } }, +}; + +describe("autoVerificationHandler", () => { + let mockReq: Partial; + let mockRes: Partial; + + beforeEach(() => { + jest.clearAllMocks(); + + mockRes = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + + process.env.SCORER_ENDPOINT = "http://test-endpoint"; + process.env.SCORER_API_KEY = "abcd"; + }); + + it("should handle valid request successfully", async () => { + const mockAddress = "0x123"; + const mockScorerId = "test-scorer"; + + mockReq = { + body: { + address: mockAddress, + scorerId: mockScorerId, + }, + }; + + const postSpy = (axios.post as jest.Mock).mockImplementation((url) => { + return Promise.resolve({ + data: { + score: mockedScore, + }, + }); + }); + + const verifySpy = (providers.verify as jest.Mock).mockImplementation( + async (type: string, payload: RequestPayload, context: ProviderContext) => { + if (expectedEvmProvidersToSucceed.has(type as PROVIDER_ID)) { + return Promise.resolve({ + valid: true, + record: { key: "verified-condition" }, + }); + } else { + return Promise.resolve({ + valid: false, + }); + } + } + ); + + const issuedCredentials: VerifiableCredential[] = []; + (issueHashedCredential as jest.Mock).mockImplementation( + (DIDKit, currentKey, address, record: { type: string }, expiresInSeconds, signatureType) => { + const credential = getMockedIssuedCredential(record.type, mockAddress); + issuedCredentials.push(credential.credential); + return Promise.resolve(credential); + } + ); + + await autoVerificationHandler(mockReq as Request, mockRes as Response); + + expect(postSpy).toHaveBeenCalledTimes(1); + expect(postSpy).toHaveBeenCalledWith( + `${process.env.SCORER_ENDPOINT}/embed/stamps/${mockAddress}`, + { + stamps: issuedCredentials, + scorer_id: mockScorerId, + }, + { + headers: { + Authorization: process.env.SCORER_API_KEY, + }, + } + ); + expect(mockRes.json).toHaveBeenCalledWith(mockedScore); + expect(verifySpy).toHaveBeenCalledTimes(expectedEvmProvidersToSucceed.size + expectedEvmProvidersToFail.size); + }); + + it("should handle axios API errors from the embed scorer API correctly", async () => { + const mockAxiosError = new Error("API error") as AxiosError; + + mockAxiosError.isAxiosError = true; + mockAxiosError.response = { + status: 500, + data: {}, + headers: {}, + statusText: "Internal Server Error", + config: {}, + }; + + const mockAddress = "0x123"; + const mockScorerId = "test-scorer"; + + mockReq = { + body: { + address: mockAddress, + scorerId: mockScorerId, + }, + }; + + const postSpy = (axios.post as jest.Mock).mockImplementation((url) => { + throw mockAxiosError; + }); + + const verifySpy = (providers.verify as jest.Mock).mockImplementation( + async (type: string, payload: RequestPayload, context: ProviderContext) => { + return Promise.resolve({ + valid: true, + record: { key: "verified-condition" }, + }); + } + ); + + const issuedCredentials: VerifiableCredential[] = []; + (issueHashedCredential as jest.Mock).mockImplementation( + (DIDKit, currentKey, address, record: { type: string }, expiresInSeconds, signatureType) => { + const credential = getMockedIssuedCredential(record.type, mockAddress); + issuedCredentials.push(credential.credential); + return Promise.resolve(credential); + } + ); + + await autoVerificationHandler(mockReq as Request, mockRes as Response); + + expect(postSpy).toHaveBeenCalledTimes(1); + expect(postSpy).toHaveBeenCalledWith( + `${process.env.SCORER_ENDPOINT}/embed/stamps/${mockAddress}`, + { + stamps: issuedCredentials, + scorer_id: mockScorerId, + }, + { + headers: { + Authorization: process.env.SCORER_API_KEY, + }, + } + ); + expect(mockRes.json).toHaveBeenCalledWith({ + error: "Error making Scorer Embed API request, received error response with code 500: {}, headers: {}", + }); + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(verifySpy).toHaveBeenCalledTimes(expectedEvmProvidersToSucceed.size + expectedEvmProvidersToFail.size); + }); + + it("should handle any errors from the embed scorer API correctly", async () => { + const mockAddress = "0x123"; + const mockScorerId = "test-scorer"; + + mockReq = { + body: { + address: mockAddress, + scorerId: mockScorerId, + }, + }; + + const postSpy = (axios.post as jest.Mock).mockImplementation((url) => { + throw new Error("Some API error"); + }); + + const verifySpy = (providers.verify as jest.Mock).mockImplementation( + async (type: string, payload: RequestPayload, context: ProviderContext) => { + return Promise.resolve({ + valid: true, + record: { key: "verified-condition" }, + }); + } + ); + + const issuedCredentials: VerifiableCredential[] = []; + (issueHashedCredential as jest.Mock).mockImplementation( + (DIDKit, currentKey, address, record: { type: string }, expiresInSeconds, signatureType) => { + const credential = getMockedIssuedCredential(record.type, mockAddress); + issuedCredentials.push(credential.credential); + return Promise.resolve(credential); + } + ); + + await autoVerificationHandler(mockReq as Request, mockRes as Response); + + expect(postSpy).toHaveBeenCalledTimes(1); + expect(postSpy).toHaveBeenCalledWith( + `${process.env.SCORER_ENDPOINT}/embed/stamps/${mockAddress}`, + { + stamps: issuedCredentials, + scorer_id: mockScorerId, + }, + { + headers: { + Authorization: process.env.SCORER_API_KEY, + }, + } + ); + expect(mockRes.json).toHaveBeenCalledWith({ + error: "Unexpected error when processing request, Error: Some API error", + }); + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(verifySpy).toHaveBeenCalledTimes(expectedEvmProvidersToSucceed.size + expectedEvmProvidersToFail.size); + }); +}); diff --git a/embed/__tests__/dummy.test.ts b/embed/__tests__/dummy.test.ts deleted file mode 100644 index 63131934ab..0000000000 --- a/embed/__tests__/dummy.test.ts +++ /dev/null @@ -1,10 +0,0 @@ - -describe("Dummy Test", () => { - - describe("testSomething", () => { - it("should just succeed", async () => { - expect(1).toEqual(1); - }); - - }); -}); diff --git a/embed/__tests__/index.test.ts b/embed/__tests__/index.test.ts new file mode 100644 index 0000000000..70bf1a2da4 --- /dev/null +++ b/embed/__tests__/index.test.ts @@ -0,0 +1,189 @@ +// ---- Testing libraries + +// jest.mock("ioredis"); + +import request from "supertest"; +import { Response, Request } from "express"; +import { apiKeyRateLimit, keyGenerator } from "../src/rate-limiter"; +import { + PassportScore, + AutoVerificationResponseBodyType, + AutoVerificationRequestBodyType, +} from "../src/autoVerification"; +import { ParamsDictionary } from "express-serve-static-core"; +import { VerifiableEip712Credential } from "@gitcoin/passport-types"; +// ---- Test subject + +const mockedScore: PassportScore = { + address: "0x0000000000000000000000000000000000000000", + score: "12", + passing_score: true, + last_score_timestamp: new Date().toISOString(), + expiration_timestamp: new Date().toISOString(), + threshold: "20.000", + error: "", + stamps: { "provider-1": { score: "12", dedup: true, expiration_date: new Date().toISOString() } }, +}; + +// const createMockVerifiableCredential = (address: string): VerifiableEip712Credential => ({ +// "@context": ["https://www.w3.org/2018/credentials/v1", "https://w3id.org/security/suites/eip712sig-2021/v1"], +// type: ["VerifiableCredential", "EVMCredential"], +// credentialSubject: { +// id: `did:pkh:eip155:1:${address}`, +// "@context": { +// hash: "https://schema.org/Text", +// provider: "https://schema.org/Text", +// address: "https://schema.org/Text", +// challenge: "https://schema.org/Text", +// metaPointer: "https://schema.org/URL", +// }, +// hash: "0x123456789", +// provider: "test-provider", +// address: address, +// challenge: "test-challenge", +// metaPointer: "https://example.com/metadata", +// }, +// issuer: "did:key:test-issuer", +// issuanceDate: new Date().toISOString(), +// expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days from now +// proof: { +// "@context": "https://w3id.org/security/suites/eip712sig-2021/v1", +// type: "EthereumEip712Signature2021", +// proofPurpose: "assertionMethod", +// proofValue: "0xabcdef1234567890", +// verificationMethod: "did:key:test-verification", +// created: new Date().toISOString(), +// eip712Domain: { +// domain: { +// name: "GitcoinVerifiableCredential", +// }, +// primaryType: "VerifiableCredential", +// types: { +// EIP712Domain: [ +// { name: "name", type: "string" }, +// { name: "version", type: "string" }, +// ], +// VerifiableCredential: [ +// { name: "id", type: "string" }, +// { name: "address", type: "string" }, +// ], +// }, +// }, +// }, +// }); + +jest.mock("../src/rate-limiter", () => { + const originalModule = jest.requireActual("../src/rate-limiter"); + + return { + ...originalModule, + apiKeyRateLimit: jest.fn((req, res) => { + return new Promise((resolve, reject) => { + resolve(10000); + }); + }), + keyGenerator: jest.fn(originalModule.keyGenerator), + }; +}); + +jest.mock("../src/autoVerification", () => { + const originalModule = jest.requireActual("../src/autoVerification"); + + return { + // __esModule: true, // Use it when dealing with esModules + ...originalModule, + autoVerificationHandler: jest.fn( + ( + req: Request, + res: Response + ): Promise => { + return new Promise((resolve, reject) => { + res.status(200).json(mockedScore); + resolve(); + }); + } + ), + }; +}); + +import { app } from "../src/index"; + +beforeEach(() => { + // CLear the spy stats + jest.clearAllMocks(); +}); + +describe("POST /embed/verify", function () { + it("handles valid verify requests", async () => { + // as each signature is unique, each request results in unique output + const payload = { + address: "0x0000000000000000000000000000000000000000", + scorerId: "123", + }; + + // create a req against the express app + const verifyRequest = await request(app) + .post("/embed/verify") + .send(payload) + .set("Accept", "application/json") + .set("X-API-KEY", "MY.SECRET-KEY"); + + expect(apiKeyRateLimit as jest.Mock).toHaveBeenCalledTimes(1); + expect(keyGenerator as jest.Mock).toHaveBeenCalledTimes(1); + expect(verifyRequest.status).toBe(200); + expect(verifyRequest.body).toStrictEqual(mockedScore); + }); + + it("handles invalid verify requests - missing api key", async () => { + // as each signature is unique, each request results in unique output + const payload = { + address: "0x0000000000000000000000000000000000000000", + scorerId: "123", + }; + + // create a req against the express app + const verifyRequest = await request(app).post("/embed/verify").send(payload).set("Accept", "application/json"); + + expect(apiKeyRateLimit as jest.Mock).toHaveBeenCalledTimes(0); + expect(keyGenerator as jest.Mock).toHaveBeenCalledTimes(1); + expect(verifyRequest.status).toBe(401); + expect(verifyRequest.body).toStrictEqual({ message: "Unauthorized! No 'X-API-KEY' present in the header!" }); + }); + + it("handles invalid verify requests - api key validation fails", async () => { + // as each signature is unique, each request results in unique output + const payload = { + address: "0x0000000000000000000000000000000000000000", + scorerId: "123", + }; + + (apiKeyRateLimit as jest.Mock).mockImplementationOnce(() => { + throw "Invalid API-KEY"; + }); + + // create a req against the express app + const verifyRequest = await request(app) + .post("/embed/verify") + .send(payload) + .set("Accept", "application/json") + .set("X-API-KEY", "MY.SECRET-KEY"); + + expect(apiKeyRateLimit as jest.Mock).toHaveBeenCalledTimes(1); + expect(keyGenerator as jest.Mock).toHaveBeenCalledTimes(1); + expect(verifyRequest.status).toBe(500); + }); +}); + +describe("POST /health", function () { + it("handles valid health requests", async () => { + // create a req against the express app + const verifyRequest = await request(app).get("/health").set("Accept", "application/json"); + + expect(apiKeyRateLimit as jest.Mock).toHaveBeenCalledTimes(0); + expect(keyGenerator as jest.Mock).toHaveBeenCalledTimes(0); + expect(verifyRequest.status).toBe(200); + expect(verifyRequest.body).toStrictEqual({ + message: "Ok", + }); + }); +}); diff --git a/embed/jest.setup.cjs b/embed/jest.setup.cjs index 7362c3139a..17f9446080 100644 --- a/embed/jest.setup.cjs +++ b/embed/jest.setup.cjs @@ -18,3 +18,4 @@ process.env.SCROLL_BADGE_PROVIDER_INFO = '{"DeveloperList#PassportCommiterLevel1#6a51c84c":{"contractAddress":"0x71A848A38fFCcA5c7A431F2BB411Ab632Fa0c456","level":1}}'; process.env.SCROLL_BADGE_ATTESTATION_SCHEMA_UID = "0xa35b5470ebb301aa5d309a8ee6ea258cad680ea112c86e456d5f2254448afc74"; +process.env.REDIS_URL = "redis://localhost:6379/0" diff --git a/embed/package.json b/embed/package.json index c1fe9129c9..a96deeeaeb 100644 --- a/embed/package.json +++ b/embed/package.json @@ -20,12 +20,19 @@ "lint:fix": "eslint --fix --ext .ts,.js,.tsx ." }, "dependencies": { + "@gitcoin/passport-identity": "^1.0.0", + "@gitcoin/passport-platforms": "^1.0.0", + "@gitcoin/passport-types": "^1.0.0", + "@spruceid/didkit-wasm-node": "^0.2.1", "axios": "^0.27.2", "cors": "^2.8.5", "dotenv": "^16.0.0", "ethers": "^6.13.4", "express": "4", + "express-rate-limit": "^7.5.0", + "ioredis": "^5.4.1", "luxon": "^2.4.0", + "rate-limit-redis": "^4.2.0", "tslint": "^6.1.3", "uuid": "^8.3.2" }, @@ -42,18 +49,21 @@ "@types/node-fetch": "latest", "@types/supertest": "^2.0.12", "@types/webpack-env": "^1.16.3", + "@typescript-eslint/parser": "^6.19.0", "babel-jest": "^29.7.0", "babel-plugin-replace-import-extension": "^1.1.3", "babel-plugin-transform-import-meta": "^2.2.1", - "eslint-plugin-prettier": "^5.1.3", + "eslint": "^8.52.0", "jest": "^29.6.4", + "prettier": "^3.1.0", + "prettier-eslint": "^16.1.2", "supertest": "^6.2.2", "ts-jest": "^29.1.1", "ts-node": "^10.9.1", "tsx": "^4.7.0", - "typescript": "^5.3.3" + "typescript": "^4.4.4" }, "resolutions": { "leveldown": "6.1.1" } -} \ No newline at end of file +} diff --git a/embed/src/autoVerification.ts b/embed/src/autoVerification.ts new file mode 100644 index 0000000000..1a95a8ba85 --- /dev/null +++ b/embed/src/autoVerification.ts @@ -0,0 +1,353 @@ +// ---- Web3 packages +import { isAddress } from "ethers"; + +// ---- Types +import { Response, Request } from "express"; +import { + PROVIDER_ID, + ValidResponseBody, + SignatureType, + VerifiableCredential, + RequestPayload, + CredentialResponseBody, + VerifiedPayload, + ProviderContext, +} from "@gitcoin/passport-types"; +import { ParamsDictionary } from "express-serve-static-core"; + +// All provider exports from platforms +import { platforms, providers, handleAxiosError } from "@gitcoin/passport-platforms"; +import { issueHashedCredential } from "@gitcoin/passport-identity"; + +import * as DIDKit from "@spruceid/didkit-wasm-node"; + +import axios from "axios"; + +const apiKey = process.env.SCORER_API_KEY; +const key = process.env.IAM_JWK; +const __issuer = DIDKit.keyToDID("key", key); +const eip712Key = process.env.IAM_JWK_EIP712; +const __eip712Issuer = DIDKit.keyToDID("ethr", eip712Key); + +const validIssuers = new Set([__issuer, __eip712Issuer]); + +export function getEd25519IssuerKey(): string { + return key; +} + +export function getEd25519Issuer(): string { + return __issuer; +} + +export function getEip712IssuerKey(): string { + return eip712Key; +} + +export function getEip712Issuer(): string { + return __eip712Issuer; +} + +export function getIssuerKey(signatureType: string): string { + return signatureType === "EIP712" ? eip712Key : key; +} + +export function hasValidIssuer(issuer: string): boolean { + return validIssuers.has(issuer); +} + +export class IAMError extends Error { + constructor(public message: string) { + super(message); + this.name = this.constructor.name; + } +} + +// return a JSON error response with a 400 status +export const errorRes = (res: Response, error: string | object, errorCode: number): Response => + res.status(errorCode).json({ error }); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const addErrorDetailsToMessage = (message: string, error: any): string => { + if (error instanceof IAMError || error instanceof Error) { + message += `, ${error.name}: ${error.message}`; + } else if (typeof error === "string") { + message += `, ${error}`; + } + return message; +}; + +export class ApiError extends Error { + constructor( + public message: string, + public code: number + ) { + super(message); + this.name = this.constructor.name; + } +} + +export class UnexpectedApiError extends ApiError { + constructor(message: string) { + super(message, 500); + this.name = this.constructor.name; + } +} + +type VerifyTypeResult = { + verifyResult: VerifiedPayload; + type: string; + error?: string; + code?: number; +}; + +export type PassportProviderPoints = { + score: string; + dedup: boolean; + expiration_date: string; +}; + +export type PassportScore = { + address: string; + score: string; + passing_score: boolean; + last_score_timestamp: string; + expiration_timestamp: string; + threshold: string; + error: string; + stamps: Record; +}; + +export async function verifyTypes( + typesByPlatform: PROVIDER_ID[][], + payload: RequestPayload +): Promise { + // define a context to be shared between providers in the verify request + // this is intended as a temporary storage for providers to share data + const context: ProviderContext = {}; + const results: VerifyTypeResult[] = []; + + await Promise.all( + // Run all platforms in parallel + typesByPlatform.map(async (platformTypes) => { + // Iterate over the types within a platform in series + // This enables providers within a platform to reliably share context + for (const _type of platformTypes) { + let type = _type as string; + let verifyResult: VerifiedPayload = { valid: false }; + let code, error; + + const realType = type; + if (type.startsWith("AllowList")) { + payload.proofs = { + ...payload.proofs, + allowList: type.split("#")[1], + }; + type = "AllowList"; + } else if (type.startsWith("DeveloperList")) { + // Here we handle the custom DeveloperList stamps + const [__type, conditionName, conditionHash, ..._rest] = type.split("#"); + payload.proofs = { + ...payload.proofs, + conditionName, + conditionHash, + }; + type = "DeveloperList"; + } + + try { + // verify the payload against the selected Identity Provider + verifyResult = await providers.verify(type, payload, context); + if (!verifyResult.valid) { + code = 403; + // TODO to be changed to just verifyResult.errors when all providers are updated + const resultErrors = verifyResult.errors; + error = resultErrors?.join(", ")?.substring(0, 1000) || "Unable to verify provider"; + if (error.includes(`Request timeout while verifying ${type}.`)) { + console.log(`Request timeout while verifying ${type}`); + // If a request times out exit loop and return results so additional requests are not made + break; + } + } + if (type === "AllowList") { + type = `AllowList#${verifyResult.record.allowList}`; + } else { + type = realType; + } + } catch (e) { + error = "Unable to verify provider"; + code = 400; + } + + results.push({ verifyResult, type, code, error }); + } + }) + ); + + return results; +} + +// return response for given payload +export const issueCredentials = async ( + typesByPlatform: PROVIDER_ID[][], + address: string, + payload: RequestPayload +): Promise => { + // if the payload includes an additional signer, use that to issue credential. + if (payload.signer) { + // We can assume that the signer is a valid address because the challenge was verified within the /verify endpoint + payload.address = payload.signer.address; + } + + const results = await verifyTypes(typesByPlatform, payload); + + return await Promise.all( + results.map(async ({ verifyResult, code: verifyCode, error: verifyError, type }) => { + let code = verifyCode; + let error = verifyError; + let record, credential; + + try { + // check if the request is valid against the selected Identity Provider + if (verifyResult.valid === true) { + // construct a set of Proofs to issue a credential against (this record will be used to generate a sha256 hash of any associated PII) + record = { + // type and address will always be known and can be obtained from the resultant credential + type: verifyResult.record.pii ? `${type}#${verifyResult.record.pii}` : type, + // version is defined by entry point + version: "0.0.0", + // extend/overwrite with record returned from the provider + ...(verifyResult?.record || {}), + }; + + const currentKey = getIssuerKey(payload.signatureType); + + // generate a VC for the given payload + ({ credential } = await issueHashedCredential( + DIDKit, + currentKey, + address, + record, + verifyResult.expiresInSeconds, + payload.signatureType + )); + } + } catch { + error = "Unable to produce a verifiable credential"; + code = 500; + } + + return { + record, + credential, + code, + error, + }; + }) + ); +}; + +export type AutoVerificationRequestBodyType = { + address: string; + scorerId: string; +}; + +type AutoVerificationFields = AutoVerificationRequestBodyType; + +export type AutoVerificationResponseBodyType = { + score: string; + threshold: string; +}; + +export const autoVerificationHandler = async ( + req: Request, + res: Response +): Promise => { + try { + const { address, scorerId } = req.body; + + if (!isAddress(address)) { + return void errorRes(res, "Invalid address", 400); + } + + const stamps = await getPassingEvmStamps({ address, scorerId }); + + const score = await addStampsAndGetScore({ address, scorerId, stamps }); + + // TODO should we issue a score VC? + return void res.json(score); + } catch (error) { + if (error instanceof ApiError || error instanceof UnexpectedApiError) { + return void errorRes(res, error.message, error.code); + } + const message = addErrorDetailsToMessage("Unexpected error when processing request", error); + return void errorRes(res, message, 500); + } +}; + +const getEvmProvidersByPlatform = ({ scorerId }: { scorerId: string }): PROVIDER_ID[][] => { + const evmPlatforms = Object.values(platforms).filter(({ PlatformDetails }) => PlatformDetails.isEVM); + + // TODO we should use the scorerId to check for any EVM stamps particular to a community, and include those here + scorerId; + + return evmPlatforms.map(({ ProviderConfig }) => + ProviderConfig.reduce((acc, platformGroupSpec) => { + return acc.concat(platformGroupSpec.providers.map(({ name }) => name)); + }, [] as PROVIDER_ID[]) + ); +}; + +export const getPassingEvmStamps = async ({ + address, + scorerId, +}: AutoVerificationFields): Promise => { + const evmProvidersByPlatform = getEvmProvidersByPlatform({ scorerId }); + + const credentialsInfo = { + address, + type: "EVMBulkVerify", + // types: evmProviders, + version: "0.0.0", + signatureType: "EIP712" as SignatureType, + }; + + const results = await issueCredentials(evmProvidersByPlatform, address, credentialsInfo); + + const ret = results + .flat() + .filter( + (credentialResponse): credentialResponse is ValidResponseBody => + (credentialResponse as ValidResponseBody).credential !== undefined + ) + .map(({ credential }) => credential); + return ret; +}; + +export const addStampsAndGetScore = async ({ + address, + scorerId, + stamps, +}: AutoVerificationFields & { stamps: VerifiableCredential[] }): Promise => { + try { + const scorerResponse: { + data?: { + score?: PassportScore; + }; + } = await axios.post( + `${process.env.SCORER_ENDPOINT}/embed/stamps/${address}`, + { + stamps, + scorer_id: scorerId, + }, + { + headers: { + Authorization: apiKey, + }, + } + ); + + return scorerResponse.data?.score; + } catch (error) { + handleAxiosError(error, "Scorer Embed API", UnexpectedApiError, [apiKey]); + } +}; diff --git a/embed/src/credentials.ts b/embed/src/credentials.ts new file mode 100644 index 0000000000..c55fdfb700 --- /dev/null +++ b/embed/src/credentials.ts @@ -0,0 +1 @@ +import { CredentialResponseBody, RequestPayload } from "@gitcoin/passport-types"; diff --git a/embed/src/index.ts b/embed/src/index.ts index 0fdcc4dbfa..75cd07d2d5 100644 --- a/embed/src/index.ts +++ b/embed/src/index.ts @@ -6,6 +6,14 @@ import express from "express"; // ---- Production plugins import cors from "cors"; +import { rateLimit } from "express-rate-limit"; +import { RedisReply, RedisStore } from "rate-limit-redis"; + +// --- Relative imports +import { keyGenerator, apiKeyRateLimit } from "./rate-limiter.js"; +import { autoVerificationHandler } from "./autoVerification.js"; +import { metadataHandler } from "./metadata.js"; +import { redis } from "./redis.js"; // ---- Config - check for all required env variables // We want to prevent the app from starting with default values or if it is misconfigured @@ -51,9 +59,14 @@ if (!process.env.EAS_FEE_USD) { configErrors.push("EAS_FEE_USD is required"); } +// Check for DB configuration +if (!process.env.REDIS_URL) { + configErrors.push("Redis configuration is required: REDIS_URL"); +} + if (configErrors.length > 0) { configErrors.forEach((error) => console.error(error)); // eslint-disable-line no-console - throw new Error("Missing required configuration"); + throw new Error("Missing required configuration: " + configErrors.join(",\n")); } // create the app and run on port @@ -65,12 +78,33 @@ app.use(express.json()); // set cors to accept calls from anywhere app.use(cors()); +// Use the rate limiting middleware +app.use( + rateLimit({ + windowMs: 60 * 1000, // We calculate the limit for a 1 minute limit ... + limit: apiKeyRateLimit, + // Redis store configuration + keyGenerator: keyGenerator, + store: new RedisStore({ + sendCommand: async (...args: string[]): Promise => { + // @ts-expect-error - Known issue: the `call` function is not present in @types/ioredis + return await redis.call(...args); + }, + }), + skip: (req, res): boolean => { + return req.path === "/health"; + }, + }) +); + // health check endpoint app.get("/health", (_req, res) => { const data = { message: "Ok", - date: new Date(), }; res.status(200).send(data); }); + +app.post("/embed/verify", autoVerificationHandler); +app.get("/embed/stamps/metadata", metadataHandler); diff --git a/embed/src/metadata.ts b/embed/src/metadata.ts new file mode 100644 index 0000000000..70683a0f5d --- /dev/null +++ b/embed/src/metadata.ts @@ -0,0 +1,48 @@ +// ---- Types +import { Request, Response } from "express"; + +// ---- Platform imports +import { platformsData } from "@gitcoin/passport-platforms"; + +export class IAMError extends Error { + constructor(public message: string) { + super(message); + this.name = this.constructor.name; + } +} + +// return a JSON error response with a 400 status +export const errorRes = (res: Response, error: string | object, errorCode: number): Response => + res.status(errorCode).json({ error }); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const addErrorDetailsToMessage = (message: string, error: any): string => { + if (error instanceof IAMError || error instanceof Error) { + message += `, ${error.name}: ${error.message}`; + } else if (typeof error === "string") { + message += `, ${error}`; + } + return message; +}; + +export class ApiError extends Error { + constructor( + public message: string, + public code: number + ) { + super(message); + this.name = this.constructor.name; + } +} + +export const metadataHandler = (_req: Request, res: Response): void => { + try { + return void res.json(platformsData); + } catch (error) { + if (error instanceof ApiError) { + return void errorRes(res, error.message, error.code); + } + const message = addErrorDetailsToMessage("Unexpected error when processing request", error); + return void errorRes(res, message, 500); + } +}; diff --git a/embed/src/rate-limiter.ts b/embed/src/rate-limiter.ts new file mode 100644 index 0000000000..c624498008 --- /dev/null +++ b/embed/src/rate-limiter.ts @@ -0,0 +1,82 @@ +import { Request, Response } from "express"; +import { redis } from "./redis.js"; +import axios from "axios"; + +function parseRateLimit(rateLimitSpec: string): number { + // Regular expression to match the format "/