Skip to content

Commit

Permalink
feat(app): checking for expired did sessions (#2142)
Browse files Browse the repository at this point in the history
* feat(app): checking for expired did sessions

* feat(ui): check session before writing to ceramic
  • Loading branch information
lucianHymer authored Feb 6, 2024
1 parent 56a854b commit cfe5a3e
Show file tree
Hide file tree
Showing 6 changed files with 178 additions and 41 deletions.
1 change: 1 addition & 0 deletions app/__test-fixtures__/contextTestHelpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ const datastoreConnectionContext = {
dbAccessToken: "token",
dbAccessTokenStatus: "idle" as DbAuthTokenStatus,
did: jest.fn() as any,
checkSessionIsValid: jest.fn().mockImplementation(() => true),
};

export const renderWithContext = (
Expand Down
30 changes: 30 additions & 0 deletions app/__tests__/components/GenericPlatform.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ describe("when user has not verified with EnsProvider", () => {
expect(fetchVerifiableCredential).toHaveBeenCalled();
});
});

it("should show success toast when credential is fetched", async () => {
const drawer = () => (
<ChakraProvider>
Expand All @@ -130,6 +131,35 @@ describe("when user has not verified with EnsProvider", () => {
expect(screen.getByText("All Ens data points verified.")).toBeInTheDocument();
});
});

it("should prompt user to refresh when session expired", async () => {
const drawer = () => (
<ChakraProvider>
<Drawer isOpen={true} placement="right" size="sm" onClose={() => {}}>
<DrawerOverlay />
<GenericPlatform
platform={new Ens.EnsPlatform()}
platFormGroupSpec={Ens.ProviderConfig}
platformScoreSpec={EnsScoreSpec}
onClose={() => {}}
/>
</Drawer>
</ChakraProvider>
);
renderWithContext(mockCeramicContext, drawer(), {
checkSessionIsValid: () => false,
});

const firstSwitch = screen.queryByTestId("select-all");
fireEvent.click(firstSwitch as HTMLElement);
const initialVerifyButton = screen.queryByTestId("button-verify-Ens");

fireEvent.click(initialVerifyButton as HTMLElement);
// Wait to see the error toast
await waitFor(() => {
expect(screen.getByText("Please refresh the page to reset your session.")).toBeInTheDocument();
});
});
});

describe("Mulitple EVM plaftorms", () => {
Expand Down
107 changes: 79 additions & 28 deletions app/__tests__/context/ceramicContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { Passport } from "@gitcoin/passport-types";
import { DatastoreConnectionContext } from "../../context/datastoreConnectionContext";
import { DID } from "dids";
import { ChakraProvider } from "@chakra-ui/react";

process.env.NEXT_PUBLIC_FF_CERAMIC_CLIENT = "on";

Expand Down Expand Up @@ -98,34 +99,37 @@ jest.mock("@gitcoin/passport-database-client", () => {
};
});

const mockComponent = () => (
<DatastoreConnectionContext.Provider
value={{
dbAccessToken: "token",
dbAccessTokenStatus: "idle",
did: {
id: "did:3:abc",
parent: "did:3:abc",
} as unknown as DID,
connect: async () => {},
disconnect: async () => {},
}}
>
<CeramicContextProvider>
<CeramicContext.Consumer>
{(value: CeramicContextState) => {
return (
<>
<div># Stamps = {value.passport && value.passport.stamps.length}</div>
<div onClick={() => value.handleAddStamps(stamps)}>handleAddStamps</div>
<div onClick={() => value.handleDeleteStamps(stampProviderIds)}>handleDeleteStamps</div>
<div onClick={() => value.handlePatchStamps(stampPatches)}>handlePatchStamps</div>
</>
);
}}
</CeramicContext.Consumer>
</CeramicContextProvider>
</DatastoreConnectionContext.Provider>
const mockComponent = ({ invalidSession }: { invalidSession?: boolean } = {}) => (
<ChakraProvider>
<DatastoreConnectionContext.Provider
value={{
dbAccessToken: "token",
dbAccessTokenStatus: "idle",
did: {
id: "did:3:abc",
parent: "did:3:abc",
} as unknown as DID,
connect: async () => {},
disconnect: async () => {},
checkSessionIsValid: () => !invalidSession,
}}
>
<CeramicContextProvider>
<CeramicContext.Consumer>
{(value: CeramicContextState) => {
return (
<>
<div># Stamps = {value.passport && value.passport.stamps.length}</div>
<div onClick={() => value.handleAddStamps(stamps)}>handleAddStamps</div>
<div onClick={() => value.handleDeleteStamps(stampProviderIds)}>handleDeleteStamps</div>
<div onClick={() => value.handlePatchStamps(stampPatches)}>handlePatchStamps</div>
</>
);
}}
</CeramicContext.Consumer>
</CeramicContextProvider>
</DatastoreConnectionContext.Provider>
</ChakraProvider>
);

describe("CeramicContextProvider syncs stamp state with ceramic", () => {
Expand Down Expand Up @@ -437,6 +441,53 @@ describe("CeramicContextProvider syncs stamp state with ceramic", () => {
console.log = oldConsoleLog;
}
});

it("should show an error toast but continue if ceramic patch fails due to invalid session", async () => {
(PassportDatabase as jest.Mock).mockImplementationOnce(() => {
return {
...passportDbMocks,
patchStamps: patchStampsMock.mockImplementationOnce(async () => {
return {
passport: {
stamps,
},
errorDetails: {},
status: "Success",
};
}),
getPassport: jest.fn().mockImplementationOnce(async () => {
return {
passport: {
stamps,
},
errorDetails: {},
status: "Success",
};
}),
};
});

const patchStampsMock = jest.fn();

(ComposeDatabase as jest.Mock).mockImplementationOnce(() => {
return {
...ceramicDbMocks,
patchStamps: patchStampsMock,
};
});

render(mockComponent({ invalidSession: true }));

await waitFor(() => fireEvent.click(screen.getByText("handlePatchStamps")));
await waitFor(() => expect(patchStampsMock).toHaveBeenCalledWith(stampPatches));
await waitFor(() =>
expect(
screen.getByText(
"Your update was not logged to Ceramic. Please refresh the page to reset your Ceramic session."
)
).toBeInTheDocument()
);
});
});

const userDid = "test-user-did";
Expand Down
37 changes: 28 additions & 9 deletions app/components/GenericPlatform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ enum VerificationStatuses {
const success = "../../assets/check-icon2.svg";
const fail = "../assets/verification-failed-bright.svg";

class InvalidSessionError extends Error {
constructor() {
super("Session is invalid");
this.name = "InvalidSessionError";
}
}

type GenericPlatformProps = PlatformProps & { onClose: () => void; platformScoreSpec: PlatformScoreSpec };

const arraysContainSameElements = (a: any[], b: any[]) => {
Expand All @@ -75,7 +82,7 @@ export const GenericPlatform = ({
const [submitted, setSubmitted] = useState(false);
const [verificationResponse, setVerificationResponse] = useState<CredentialResponseBody[]>([]);
const [payloadModalIsOpen, setPayloadModalIsOpen] = useState(false);
const { did } = useDatastoreConnectionContext();
const { did, checkSessionIsValid } = useDatastoreConnectionContext();
// const { handleFetchCredential } = useContext(StampClaimingContext);

// --- Chakra functions
Expand Down Expand Up @@ -155,6 +162,7 @@ export const GenericPlatform = ({
setLoading(true);
try {
if (!did) throw new Error("No DID found");

const state = `${platform.path}-` + generateUID(10);
const providerPayload = (await platform.getProviderPayload({
state,
Expand All @@ -173,6 +181,8 @@ export const GenericPlatform = ({
return;
}

if (!checkSessionIsValid()) throw new InvalidSessionError();

const verifyCredentialsResponse = await fetchVerifiableCredential(
iamUrl,
{
Expand Down Expand Up @@ -248,14 +258,23 @@ export const GenericPlatform = ({

setLoading(false);
} catch (e) {
console.error(e);
datadogLogs.logger.error("Verification Error", { error: e, platform: platform.platformId });
doneToast(
"Verification Failed",
"There was an error verifying your stamp. Please try again.",
fail,
platform.platformId as PLATFORM_ID
);
if (e instanceof InvalidSessionError) {
doneToast(
"Session Invalid",
"Please refresh the page to reset your session.",
fail,
platform.platformId as PLATFORM_ID
);
} else {
console.error(e);
datadogLogs.logger.error("Verification Error", { error: e, platform: platform.platformId });
doneToast(
"Verification Failed",
"There was an error verifying your stamp. Please try again.",
fail,
platform.platformId as PLATFORM_ID
);
}
} finally {
setLoading(false);
setSubmitted(true);
Expand Down
29 changes: 27 additions & 2 deletions app/context/ceramicContext.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useState, useRef, useMemo } from "react";
import { createContext, useContext, useEffect, useState, useRef, useMemo, useCallback } from "react";
import {
ComposeDBMetadataRequest,
ComposeDBSaveStatus,
Expand Down Expand Up @@ -50,6 +50,8 @@ import { PlatformProps } from "../components/GenericPlatform";

import { CERAMIC_CACHE_ENDPOINT, IAM_VALID_ISSUER_DIDS } from "../config/stamp_config";
import { useDatastoreConnectionContext } from "./datastoreConnectionContext";
import { useToast } from "@chakra-ui/react";
import { DoneToastContent } from "../components/DoneToastContent";

// -- Trusted IAM servers DID
const CACAO_ERROR_STATUSES: PassportLoadStatus[] = ["PassportCacaoError", "StampCacaoError"];
Expand Down Expand Up @@ -343,9 +345,11 @@ export const CeramicContextProvider = ({ children }: { children: any }) => {
const [database, setDatabase] = useState<PassportDatabase | undefined>(undefined);

const address = useWalletStore((state) => state.address);
const { dbAccessToken, did } = useDatastoreConnectionContext();
const { dbAccessToken, did, checkSessionIsValid } = useDatastoreConnectionContext();
const { refreshScore, fetchStampWeights } = useContext(ScorerContext);

const toast = useToast();

useEffect(() => {
return () => {
clearAllProvidersState();
Expand Down Expand Up @@ -414,6 +418,24 @@ export const CeramicContextProvider = ({ children }: { children: any }) => {
}
}, [ceramicClient]);

const checkAndAlertInvalidCeramicSession = useCallback(() => {
if (!checkSessionIsValid()) {
toast({
render: (result: any) => (
<DoneToastContent
title="Ceramic Session Invalid"
body="Your update was not logged to Ceramic. Please refresh the page to reset your Ceramic session."
icon="../assets/verification-failed-bright.svg"
result={result}
/>
),
duration: 9000,
isClosable: true,
});
throw new Error("Session Expired");
}
}, [toast, checkSessionIsValid]);

const passportLoadSuccess = (
database: PassportDatabase,
passport?: Passport,
Expand Down Expand Up @@ -514,6 +536,7 @@ export const CeramicContextProvider = ({ children }: { children: any }) => {
if (ceramicClient && addResponse.passport) {
(async () => {
try {
checkAndAlertInvalidCeramicSession();
const composeDBAddResponse = await ceramicClient.addStamps(stamps);
const composeDBMetadata = processComposeDBMetadata(addResponse.passport, {
adds: composeDBAddResponse,
Expand Down Expand Up @@ -590,6 +613,7 @@ export const CeramicContextProvider = ({ children }: { children: any }) => {
if (ceramicClient && patchResponse.passport) {
(async () => {
try {
checkAndAlertInvalidCeramicSession();
const composeDBPatchResponse = await ceramicClient.patchStamps(stampPatches);
const composeDBMetadata = processComposeDBMetadata(patchResponse.passport, composeDBPatchResponse);
await database.patchStampComposeDBMetadata(composeDBMetadata);
Expand Down Expand Up @@ -618,6 +642,7 @@ export const CeramicContextProvider = ({ children }: { children: any }) => {
if (ceramicClient && deleteResponse.status === "Success" && deleteResponse.passport?.stamps) {
(async () => {
try {
checkAndAlertInvalidCeramicSession();
const responses = await ceramicClient.deleteStamps(providerIds);
processComposeDBMetadata(deleteResponse.passport, { adds: [], deletes: responses });
} catch (e) {
Expand Down
15 changes: 13 additions & 2 deletions app/context/datastoreConnectionContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ export type DatastoreConnectionContextState = {
did?: DID;
disconnect: (address: string) => Promise<void>;
connect: (address: string, provider: Eip1193Provider) => Promise<void>;
checkSessionIsValid: () => boolean;
};

export const DatastoreConnectionContext = createContext<DatastoreConnectionContextState>({
dbAccessTokenStatus: "idle",
disconnect: async (address: string) => {},
connect: async () => {},
checkSessionIsValid: () => false,
});

// In the app, the context hook should be used. This is only exported for testing
Expand All @@ -47,6 +49,7 @@ export const useDatastoreConnection = () => {
const [dbAccessToken, setDbAccessToken] = useState<string | undefined>();

const [did, setDid] = useState<DID>();
const [checkSessionIsValid, setCheckSessionIsValid] = useState<() => boolean>(() => false);

useEffect(() => {
// Clear status when wallet disconnected
Expand Down Expand Up @@ -183,6 +186,11 @@ export const useDatastoreConnection = () => {
if (session) {
await loadDbAccessToken(address, session.did);
setDid(session.did);

// session.isExpired looks like a static variable so this looks like a bug,
// but isExpired is a getter, so it's actually checking the current status
// whenever checkSessionIsValid is called
setCheckSessionIsValid(() => () => !session.isExpired);
}
} catch (error) {
await handleConnectionError(sessionKey, dbCacheTokenKey);
Expand Down Expand Up @@ -216,11 +224,13 @@ export const useDatastoreConnection = () => {
disconnect,
dbAccessToken,
dbAccessTokenStatus,
checkSessionIsValid,
};
};

export const DatastoreConnectionContextProvider = ({ children }: { children: any }) => {
const { dbAccessToken, dbAccessTokenStatus, disconnect, connect, did } = useDatastoreConnection();
const { dbAccessToken, dbAccessTokenStatus, disconnect, connect, did, checkSessionIsValid } =
useDatastoreConnection();

const providerProps = useMemo(
() => ({
Expand All @@ -229,8 +239,9 @@ export const DatastoreConnectionContextProvider = ({ children }: { children: any
disconnect,
dbAccessToken,
dbAccessTokenStatus,
checkSessionIsValid,
}),
[dbAccessToken, dbAccessTokenStatus, did, connect, disconnect]
[dbAccessToken, dbAccessTokenStatus, did, connect, disconnect, checkSessionIsValid]
);

return <DatastoreConnectionContext.Provider value={providerProps}>{children}</DatastoreConnectionContext.Provider>;
Expand Down

0 comments on commit cfe5a3e

Please sign in to comment.