diff --git a/app/.env-example.env b/app/.env-example.env index 78115dabbb..7709cb6121 100644 --- a/app/.env-example.env +++ b/app/.env-example.env @@ -14,6 +14,7 @@ NEXT_PUBLIC_PASSPORT_COINBASE_CALLBACK=http://localhost:3000/ NEXT_PUBLIC_PASSPORT_IDENA_CALLBACK=http://localhost:3000/ NEXT_PUBLIC_PASSPORT_IDENA_WEB_APP=https://app.idena.io/ NEXT_PUBLIC_PASSPORT_CIVIC_CALLBACK=http://localhost:3000/ +NEXT_PUBLIC_PASSPORT_OUTDID_CALLBACK=http://localhost:3000/ NEXT_PUBLIC_PASSPORT_SIGNER_URL=http://localhost:8000/ diff --git a/app/__test-fixtures__/contextTestHelpers.tsx b/app/__test-fixtures__/contextTestHelpers.tsx index f80c120e38..ee793dbb14 100644 --- a/app/__test-fixtures__/contextTestHelpers.tsx +++ b/app/__test-fixtures__/contextTestHelpers.tsx @@ -50,6 +50,10 @@ export const makeTestCeramicContext = (initialState?: Partial \ No newline at end of file diff --git a/iam/.env-example.env b/iam/.env-example.env index f2583f413b..0bad107c5a 100644 --- a/iam/.env-example.env +++ b/iam/.env-example.env @@ -23,6 +23,8 @@ COINBASE_CLIENT_ID=MY_COINBASE_CLIENT_ID COINBASE_CLIENT_SECRET=MY_COINBASE_CLIENT_SECRET COINBASE_CALLBACK=http://localhost:3000/ TRUSTA_LABS_ACCESS_TOKEN=trusta_labs_access_token +OUTDID_API_KEY= +OUTDID_API_SECRET= CURRENT_ENV=development EXIT_ON_UNHANDLED_ERROR=true diff --git a/platforms/src/Outdid/App-Bindings.ts b/platforms/src/Outdid/App-Bindings.ts new file mode 100644 index 0000000000..883788360f --- /dev/null +++ b/platforms/src/Outdid/App-Bindings.ts @@ -0,0 +1,50 @@ +import axios from "axios"; +import { AppContext, PlatformOptions, ProviderPayload } from "../types"; +import { Platform } from "../utils/platform"; + +export class OutdidPlatform extends Platform { + platformId = "Outdid"; + path = "outdid"; + clientId: string = null; + redirectUri: string = null; + + banner = { + heading: "Outdid is an app which scans the NFC chip of your passport and generates a Zero-Knowledge Proof that you are a unique human. Most importantly, all of your private data stays on your phone - not even Outdid can see it :)" + }; + + constructor(options: PlatformOptions = {}) { + super(); + this.clientId = options.clientId as string; + this.redirectUri = options.redirectUri as string; + } + + async getProviderPayload(appContext: AppContext): Promise { + const { successRedirect, verificationID } = (await axios.post(`${process.env.NEXT_PUBLIC_PASSPORT_PROCEDURE_URL?.replace( + /\/*?$/, + "" + )}/outdid/connect`, { + callback: `${this.redirectUri}?error=false&code=null&state=outdid`, + userDid: appContext.userDid, + })).data as { successRedirect: string, verificationID: string }; + const width = 800; + const height = 900; + const left = appContext.screen.width / 2 - width / 2; + const top = appContext.screen.height / 2 - height / 2; + + // Pass data to the page via props + appContext.window.open( + successRedirect, + "_blank", + `toolbar=no, location=no, directories=no, status=no, menubar=no, resizable=no, copyhistory=no, width=${width}, height=${height}, top=${top}, left=${left}` + ); + + const response = await appContext.waitForRedirect(this); + + return { + verificationID, + code: "success", + sessionKey: response.state, + userDid: appContext.userDid, + }; + } +} \ No newline at end of file diff --git a/platforms/src/Outdid/Providers-config.ts b/platforms/src/Outdid/Providers-config.ts new file mode 100644 index 0000000000..b077fdc02d --- /dev/null +++ b/platforms/src/Outdid/Providers-config.ts @@ -0,0 +1,26 @@ +import { PlatformSpec, PlatformGroupSpec, Provider } from "../types"; +import { OutdidProvider } from "./Providers/outdid"; + +export const PlatformDetails: PlatformSpec = { + icon: "./assets/outdidStampIcon.svg", + platform: "Outdid", + name: "Outdid", + description: "Outdid's free ZK ID verification brings a strong sybil signal with complete privacy and anonymity.", + connectMessage: "Connect Account", + website: "https://outdid.io/", +}; + +export const ProviderConfig: PlatformGroupSpec[] = [ + { + platformGroup: "Name of the Stamp platform group", + providers: [ + { + title: "ZK-prove your identity with Outdid", + description: "Outdid uses zero-knowledge cryptography to ensure you are a unique human without revealing any personal information.", + name: "Outdid", + }, + ] + }, +]; + +export const providers: Provider[] = [new OutdidProvider()] \ No newline at end of file diff --git a/platforms/src/Outdid/Providers/outdid.ts b/platforms/src/Outdid/Providers/outdid.ts new file mode 100644 index 0000000000..24860298dc --- /dev/null +++ b/platforms/src/Outdid/Providers/outdid.ts @@ -0,0 +1,54 @@ +import { type Provider, type ProviderOptions } from "../../types"; +import type { RequestPayload, VerifiedPayload } from "@gitcoin/passport-types"; +import axios from "axios"; + +type OutdidVerification = { + uniqueID: string, + verificationName: string, + status: string, + parameters: { uniqueness: boolean }, +} + +export class OutdidProvider implements Provider { + // The type will be determined dynamically, from the options passed in to the constructor + type = "Outdid"; + + // Options can be set here and/or via the constructor + _options: ProviderOptions = {}; + + // construct the provider instance with supplied options + constructor(options: ProviderOptions = {}) { + this._options = { ...this._options, ...options }; + } + + async verify(payload: RequestPayload): Promise { + let valid = false; + let id = ""; + const errors: string[] = []; + try { + const verificationID = payload.proofs?.verificationID; + + const verificationData: OutdidVerification = await axios.get(`https://api.outdid.io/v1/verification-request?verificationID=${verificationID}`) + .then((response: {data: OutdidVerification}) => response.data); + + id = verificationData.uniqueID; + const did = verificationData.verificationName; + if (did === undefined || did === "" || did !== payload.proofs?.userDid) { + errors.push("User verification with Outdid failed."); + } else if (id === undefined || id === "" || JSON.stringify(verificationData.parameters) !== JSON.stringify({ uniqueness: true })) { + errors.push("User could not be verified"); + } else { + valid = (verificationData.status === "succeeded"); + } + } catch (e) { + errors.push("Error verifying identity with Outdid: " + String(e)); + valid = false; + } + + return { + valid, + errors, + record: { id }, + }; + } +} \ No newline at end of file diff --git a/platforms/src/Outdid/__tests__/outdid.test.ts b/platforms/src/Outdid/__tests__/outdid.test.ts new file mode 100644 index 0000000000..e6f45aa4b9 --- /dev/null +++ b/platforms/src/Outdid/__tests__/outdid.test.ts @@ -0,0 +1,162 @@ +/* eslint-disable */ +// ---- Test subject +import { RequestPayload } from "@gitcoin/passport-types"; +import { OutdidProvider } from "../Providers/outdid"; +import { outdidRequestVerification } from "../procedures/outdidVerification"; + +// ----- Libs +import axios from "axios"; + +jest.mock("axios"); + +const mockedAxios = axios as jest.Mocked; + +const userDid = "mock user DID"; +const userID = "mock unique user ID"; +const verificationID = "11112222"; +const redirect = process.env.NEXT_PUBLIC_PASSPORT_OUTDID_CALLBACK; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("Attempt Outdid verification", function () { + async function mockProvider(mockedRequest: object) { + mockedAxios.get.mockImplementation(async (url, config) => { + return mockedRequest; + }); + + const outdid = new OutdidProvider(); + const outdidPayload = await outdid.verify({ + proofs: { + verificationID, + userDid, + }, + } as unknown as RequestPayload); + + expect(mockedAxios.get).toBeCalledTimes(1); + expect(mockedAxios.get).toBeCalledWith( + `https://api.outdid.io/v1/verification-request?verificationID=${verificationID}`, + ); + + return outdidPayload; + } + + it("handles valid verification attempt", async () => { + const outdidPayload = await mockProvider({ + data: { + verificationName: userDid, + uniqueID: userID, + parameters: { uniqueness: true }, + status: "succeeded", + }, + status: 200 + }); + expect(outdidPayload).toEqual({ + valid: true, + errors: [], + record: { + id: userID, + }, + }); + }); + + it("handles expired verification attempt", async () => { + const outdidPayload = await mockProvider({ + data: { + verificationName: userDid, + uniqueID: userID, + parameters: { uniqueness: true }, + status: "expired", + }, + status: 200 + }); + expect(outdidPayload).toEqual({ + valid: false, + errors: [], + record: { + id: userID, + }, + }); + }); + + it("handles unexpected response from Outdid", async () => { + const outdidPayload = await mockProvider({ + status: 500 + }); + + expect(outdidPayload).toMatchObject({ valid: false }); + }); + + it("result should not be valid in case user DID is not returned from Outdid", async () => { + const outdidPayload = await mockProvider({ + data: { + verificationName: undefined, + uniqueID: userID, + parameters: { uniqueness: true }, + status: "succeeded", + }, + status: 200 + }); + expect(outdidPayload).toMatchObject({ valid: false }); + }); + + + it("result should not be valid in case no parameters are returned from Outdid", async () => { + const outdidPayload = await mockProvider({ + data: { + verificationName: userDid, + parameters: undefined, + status: "failed", + }, + status: 200 + }); + expect(outdidPayload).toMatchObject({ valid: false }); + }); + + it("result should not be valid in case no user identifier is returned from Outdid", async () => { + const outdidPayload = await mockProvider({ + data: { + verificationName: userDid, + uniqueID: undefined, + parameters: { uniqueness: true }, + status: "succeeded", + }, + status: 200 + }); + expect(outdidPayload).toMatchObject({ valid: false }); + }); +}); + + +describe("Attempt Outdid request verification", function () { + it("handles valid verification", async () => { + const successRedirect = "http://example.com"; + mockedAxios.post.mockImplementation(async (url, _, config) => { + return { + data: { + successRedirect, + verificationID, + }, + status: 200, + }; + }); + const verificationRequestData = await outdidRequestVerification(userDid, redirect); + + expect(mockedAxios.post).toBeCalledTimes(1); + expect(mockedAxios.post).toBeCalledWith( + `https://api.outdid.io/v1/verification-request?apiKey=${process.env.OUTDID_API_KEY}&apiSecret=${process.env.OUTDID_API_SECRET}`, + expect.objectContaining({ + verificationParameters: { uniqueness: true }, + verificationType: "icao", + verificationName: userDid, + redirect, + }), + ); + + expect(verificationRequestData).toEqual({ + successRedirect, + verificationID + }); + }); +}); \ No newline at end of file diff --git a/platforms/src/Outdid/index.ts b/platforms/src/Outdid/index.ts new file mode 100644 index 0000000000..f8c543489a --- /dev/null +++ b/platforms/src/Outdid/index.ts @@ -0,0 +1,3 @@ +export { OutdidPlatform } from "./App-Bindings"; +export { ProviderConfig, PlatformDetails, providers } from "./Providers-config"; +export { OutdidProvider } from "./Providers/outdid"; \ No newline at end of file diff --git a/platforms/src/Outdid/procedures/outdidVerification.ts b/platforms/src/Outdid/procedures/outdidVerification.ts new file mode 100644 index 0000000000..7ef2030c17 --- /dev/null +++ b/platforms/src/Outdid/procedures/outdidVerification.ts @@ -0,0 +1,15 @@ +import axios from "axios"; + +type OutdidVerificationResponse = { successRedirect: string, verificationID: string, userDid?: string }; + +export const outdidRequestVerification = async (userDid: string, redirect: string): Promise => { + // request a verification containing a unique user identifier + return await axios.post(`https://api.outdid.io/v1/verification-request?apiKey=${process.env.OUTDID_API_KEY}&apiSecret=${process.env.OUTDID_API_SECRET}`, { + verificationParameters: { uniqueness: true }, + verificationType: "icao", + verificationName: userDid, + redirect, + }).then((response: { data: OutdidVerificationResponse }) => { + return response.data; + }); +} diff --git a/platforms/src/platforms.ts b/platforms/src/platforms.ts index 0a5b20656d..5db5a0b315 100644 --- a/platforms/src/platforms.ts +++ b/platforms/src/platforms.ts @@ -19,6 +19,7 @@ import * as Holonym from "./Holonym"; import * as Idena from "./Idena"; import * as Civic from "./Civic"; import * as TrustaLabs from "./TrustaLabs"; +import * as Outdid from "./Outdid"; import { PlatformSpec, PlatformGroupSpec, Provider } from "./types"; type PlatformConfig = { @@ -50,6 +51,7 @@ const platforms: Record = { Idena, Civic, TrustaLabs, + Outdid, }; if (process.env.NEXT_PUBLIC_FF_NEW_POAP_STAMPS === "on") { diff --git a/platforms/src/procedure-router.ts b/platforms/src/procedure-router.ts index 4d9de71aaa..0cf775e15a 100644 --- a/platforms/src/procedure-router.ts +++ b/platforms/src/procedure-router.ts @@ -5,6 +5,7 @@ import * as twitterOAuth from "./Twitter/procedures/twitterOauth"; import { triggerBrightidSponsorship, verifyBrightidContextId } from "./Brightid/procedures/brightid"; import path from "path"; import * as idenaSignIn from "./Idena/procedures/idenaSignIn"; +import { outdidRequestVerification } from "./Outdid/procedures/outdidVerification"; export const router = Router(); @@ -149,3 +150,16 @@ router.post("/idena/authenticate", (req: Request, res: Response): void => { } }); }); + +router.post("/outdid/connect", (req: Request, res: Response): void => { + const body = req.body as { userDid?: string, callback?: string }; + if (body && body.userDid && body.callback) { + outdidRequestVerification(body.userDid, body.callback).then((response) => { + res.status(200).send(response); + }).catch((_) => { + res.status(400).send(); + }); + } else { + res.status(400).send(); + } +}); \ No newline at end of file diff --git a/types/src/index.d.ts b/types/src/index.d.ts index 9f64179c67..03e973cc85 100644 --- a/types/src/index.d.ts +++ b/types/src/index.d.ts @@ -357,7 +357,8 @@ export type PLATFORM_ID = | "Civic" | "GrantsStack" | "ZkSync" - | "TrustaLabs"; + | "TrustaLabs" + | "Outdid"; export type PROVIDER_ID = | "Signer" @@ -430,7 +431,8 @@ export type PROVIDER_ID = | "ETHScore#90" | "ETHDaysActive#50" | "ETHGasSpent#0.25" - | "ETHnumTransactions#100"; + | "ETHnumTransactions#100" + | "Outdid"; export type StampBit = { bit: number;