Skip to content

Commit

Permalink
feat: fractal ID verification, and tests; missing error handling and …
Browse files Browse the repository at this point in the history
…unhappy paths
  • Loading branch information
juliosantos committed Nov 14, 2022
1 parent 99cbc94 commit 267c40a
Show file tree
Hide file tree
Showing 3 changed files with 237 additions and 16 deletions.
102 changes: 93 additions & 9 deletions app/components/PlatformCards/FractalIdPlatform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,45 +44,129 @@ import {
CredentialResponseBody,
} from "@gitcoin/passport-types";

import { useConnectWallet } from "@web3-onboard/react";

const iamUrl = process.env.NEXT_PUBLIC_PASSPORT_IAM_URL || "";

// Each provider is recognised by its ID
const platformId: PLATFORM_ID = "FractalId";

// TODO change app name and ID
const fractalAuthMessage = [
"I authorize Defistarter (GKYNcHbtCZ6S315O8zBTgxptvMqy4LIPsnI4EEmj_8c) to get a proof from Fractal that:",
"- I passed KYC level uniqueness",
].join("\n");

export default function FractalIdPlatform(): JSX.Element {
const [{ wallet }, connect, disconnect] = useConnectWallet();
const { address, signer } = useContext(UserContext);
const { handleAddStamps, allProvidersState, userDid } = useContext(CeramicContext);
const [verificationInProgress, setVerificationInProgress] = useState(false);
const [isLoading, setLoading] = useState(false);
const [canSubmit, setCanSubmit] = useState(false);

// find all providerIds
const providerIds =
STAMP_PROVIDERS["FractalId"]?.reduce((all, stamp) => {
return all.concat(stamp.providers?.map((provider) => provider.name as PROVIDER_ID));
}, [] as PROVIDER_ID[]) || [];

// SelectedProviders will be passed in to the sidebar to be filled there...
const [verifiedProviders, setVerifiedProviders] = useState<PROVIDER_ID[]>(
providerIds.filter((providerId) => typeof allProvidersState[providerId]?.stamp?.credential !== "undefined")
);
// SelectedProviders will be passed in to the sidebar to be filled there...
const [selectedProviders, setSelectedProviders] = useState<PROVIDER_ID[]>([...verifiedProviders]);

// any time we change selection state...
useEffect(() => {
if (selectedProviders.length !== verifiedProviders.length) {
setCanSubmit(true);
}
}, [selectedProviders, verifiedProviders]);

// --- Chakra functions
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();

async function handleFetchFractalId(): Promise<void> {
// TODO
}
const handleFetchCredential = async (): void => {
setLoading(true);

const address = wallet.accounts[0].address;

let signatureResponse;
try {
signatureResponse = await wallet.provider.send(
"personal_sign",
[fractalAuthMessage, address],
);
} catch (e) {
// TODO error handling
console.log(e);
}

fetchVerifiableCredential(
iamUrl,
{
type: platformId,
types: selectedProviders,
version: "0.0.0",
address: address ?? "",
proofs: {
fractalAuthMessage,
address: address,
fractalAuthSignature: signatureResponse.result,
},
},
signer as { signMessage: (message: string) => Promise<string> },
).then(async (verified: VerifiableCredentialRecord): Promise<void> => {
console.log(verified);

// TODO error handling
switch(verified.credentials[0].error) {
case "invalid_message_schema":
console.log("invalid_message_schema, ABORT");
break;
case "user_not_found":
console.log(`user_not_found, please come to fractal with address ${address}`);
break;
case "user_pending":
console.log("user_pending, please try again later");
break;
}

const vcs =
verified.credentials
?.map((cred: CredentialResponseBody): Stamp | undefined => {
if (!cred.error) {
// add each of the requested/received stamps to the passport...
return {
provider: cred.record?.type as PROVIDER_ID,
credential: cred.credential as VerifiableCredential,
};
}
})
.filter((v: Stamp | undefined) => v) || [];

await handleAddStamps(vcs as Stamp[]);

const actualVerifiedProviders = providerIds.filter(
(providerId) =>
!!vcs.find((vc: Stamp | undefined) => vc?.credential?.credentialSubject?.provider === providerId)
);

setCanSubmit(false);
setLoading(false);

toast({
duration: 5000,
isClosable: true,
render: (result) => <DoneToastContent platformId={platformId} result={result} />,
});

})
.catch((e: any): void => {
setSelectedProviders([]);
})
.finally((): void => {
setLoading(false);
});
};

return (
<SideBarContent
Expand All @@ -95,7 +179,7 @@ export default function FractalIdPlatform(): JSX.Element {
verifyButton={
<button
disabled={!canSubmit}
onClick={handleFetchFractalId}
onClick={handleFetchCredential}
data-testid="button-verify-fractalid"
className="sidebar-verify-btn"
>
Expand Down
98 changes: 94 additions & 4 deletions iam/__tests__/fractalId.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,103 @@ import { RequestPayload } from "@gitcoin/passport-types";

// ----- Libs
import axios from "axios";

jest.mock("axios");

const mockedAxios = axios as jest.Mocked<typeof axios>;

jest.unmock("ethers");
const ethers = require("ethers");

const [address, fractalId, approvedAt, validUntil, credentialText] = [
ethers.Wallet.createRandom().address.toLowerCase(),
`0x${Math.floor(Math.random() * 1e10).toString(16)}`,
Math.floor(Date.now() * 1e-3 - Math.random() * 60 * 60 * 24 * 365),
Math.floor(Date.now() * 1e-3 + Math.random() * 60 * 60 * 24),
"level:uniqueness;citizenship_not:;residency_not:",
];

const fractalMessage = [address, fractalId, approvedAt, validUntil, credentialText].join(";");

const fractalIssuerWallet = ethers.Wallet.createRandom();

let verifyRequestPayload = { proofs: { address } } as unknown as RequestPayload;

let proof: string;
let fractalIdProvider: FractalIdProvider;
let validFractalResponse: any;

beforeAll(async () => {
proof = await fractalIssuerWallet.signMessage(fractalMessage);
});

beforeEach(async () => {
jest.clearAllMocks();

validFractalResponse = {
status: 200,
data: { address, approvedAt, fractalId, proof, validUntil },
};

mockedAxios.get.mockImplementation(async (url, config) => validFractalResponse);

fractalIdProvider = new FractalIdProvider({
fractalIssuer: fractalIssuerWallet.address,
});
});

describe("Attempt verification", function () {
it("handles valid verification attempt", async () => {
// TODO
it("valid", async () => {
const result = await fractalIdProvider.verify(verifyRequestPayload);

expect(result).toMatchObject({
valid: true,
record: {
fractalUserId: fractalId,
},
});
});

it("invalid: wrong credential subject", async () => {
const result = await fractalIdProvider.verify({ proofs: { address: "0x0" } } as unknown as RequestPayload);

expect(result).toMatchObject({
valid: false,
error: ["Wrong credential subject"],
});
});

it("invalid: wrong credential issuer", async () => {
validFractalResponse.data.proof = await ethers.Wallet.createRandom().signMessage(fractalMessage);

const result = await fractalIdProvider.verify(verifyRequestPayload);

expect(result).toMatchObject({
valid: false,
error: ["Wrong credential issuer"],
});
});

it("invalid: credential expired", async () => {
validFractalResponse.data.validUntil = Math.floor(Date.now() * 1e-3) - 1;

const result = await fractalIdProvider.verify(verifyRequestPayload);

expect(result).toMatchObject({
valid: false,
error: ["Expired credential"],
});
});

it("invalid: not found or pending (404 with variable message)", async () => {
mockedAxios.isAxiosError.mockImplementation(() => true);
const error = Math.random().toString();

mockedAxios.get.mockRejectedValue({ response: { status: 404, data: { error } } });

const result = await fractalIdProvider.verify(verifyRequestPayload);

expect(result).toMatchObject({
valid: false,
error: [error],
});
});
});
53 changes: 50 additions & 3 deletions iam/src/providers/fractalId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,75 @@
import type { Provider, ProviderOptions } from "../types";
import type { RequestPayload, VerifiedPayload } from "@gitcoin/passport-types";

import axios from "axios";
import { utils as ethersUtils } from "ethers";

export type FractalResponse = {
address: string;
approvedAt: number;
fractalId: string;
proof: string;
validUntil: number;
error: string;
};

export class FractalIdProvider implements Provider {
// Give the provider a type so that we can select it with a payload
type = "FractalId";
// Options can be set here and/or via the constructor
_options = {};
_options: ProviderOptions = {};

// construct the provider instance with supplied options
constructor(options: ProviderOptions = {}) {
this._options = { ...this._options, ...options };
}

async verify(payload: RequestPayload): Promise<VerifiedPayload> {
const fractalIssuer = this._options.fractalIssuer || "0xacD08d6714ADba531beFF582e6FD5DA1AFD6bc65";
try {
await new Promise(() => undefined);
const response = await axios.get("https://credentials.fractal.id", {
params: new URLSearchParams({
message: payload.proofs.fractalAuthMessage,
signature: payload.proofs.fractalAuthSignature,
}),
});

const fractalCredential = response.data as FractalResponse;

const fractalMessage = [
fractalCredential.address.toLowerCase(),
fractalCredential.fractalId,
fractalCredential.approvedAt,
fractalCredential.validUntil,
"level:uniqueness;citizenship_not:;residency_not:",
].join(";");

if (fractalCredential.validUntil * 1e3 < Date.now()) {
throw new Error("Expired credential");
}

if (fractalCredential.address.toLowerCase() !== payload.proofs.address.toLowerCase()) {
throw new Error("Wrong credential subject");
}

if (ethersUtils.verifyMessage(fractalMessage, fractalCredential.proof) !== fractalIssuer) {
throw new Error("Wrong credential issuer");
}

return {
valid: true,
record: {
fractalUserId: "42",
fractalUserId: fractalCredential.fractalId,
},
};
} catch (e) {
if (axios.isAxiosError(e)) {
const fractalError = e.response.data as FractalResponse;
return { valid: false, error: [fractalError.error] };
} else if (e instanceof Error) {
return { valid: false, error: [e.message] };
}

return { valid: false };
}
}
Expand Down

0 comments on commit 267c40a

Please sign in to comment.