diff --git a/.changeset/long-lions-behave.md b/.changeset/long-lions-behave.md new file mode 100644 index 0000000..a42b8d0 --- /dev/null +++ b/.changeset/long-lions-behave.md @@ -0,0 +1,5 @@ +--- +"@fleek-platform/login-button": minor +--- + +Extend session dismissal check with cookie mismatching locals storage session details diff --git a/src/providers/DynamicProvider.tsx b/src/providers/DynamicProvider.tsx index 8ed60d3..b274d5b 100644 --- a/src/providers/DynamicProvider.tsx +++ b/src/providers/DynamicProvider.tsx @@ -9,7 +9,7 @@ import { type TriggerLoginModal, type TriggerLogout, useAuthStore } from '../sto import { cookies } from '../utils/cookies'; import type { LoginProviderChildrenProps } from './LoginProvider'; import { clearStorageByMatchTerm } from '../utils/browser'; -import { decodeAccessToken } from '../utils/token'; +import { decodeAccessToken, truncateMiddle } from '../utils/token'; import cssOverrides from '../css/index.css'; type DynamicUtilsProps = { @@ -47,21 +47,29 @@ const validateUserSession = async ({ graphqlApiUrl, projectId, onAuthenticationFailure, + onAuthenticationSuccess, }: { accessToken: string; graphqlApiUrl: string; projectId: string; onAuthenticationFailure: () => void; + onAuthenticationSuccess: () => void; }): Promise => { try { const { success: meSuccess } = await me(graphqlApiUrl, accessToken); const { success: projectSuccess } = await project(graphqlApiUrl, accessToken, projectId); - if (!meSuccess || !projectSuccess) { - onAuthenticationFailure(); - return false; - } + if (!meSuccess || !projectSuccess) throw Error('Unexpected user session details'); + + const cookieAccessToken = cookies.get('accessToken'); + + if (cookieAccessToken !== accessToken) + throw Error( + `Expected ${truncateMiddle(accessToken)} but got ${typeof cookieAccessToken === 'string' ? truncateMiddle(cookieAccessToken) : typeof cookieAccessToken}`, + ); + + typeof onAuthenticationSuccess === 'function' && onAuthenticationSuccess(); return true; } catch (error) { @@ -90,12 +98,13 @@ export const DynamicProvider: FC = ({ children, graphqlApi const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); - // TODO: Remove useCallback to inspect re-triggers const onLogout = useCallback(() => { cookies.reset(); // TODO: Make sure the reset is not clearing // the trigger callbacks resetStore(); + // TODO: Dashboard has a concurrent process + // that should also match these requirements // Clear critical stores for (const item of ['dynamic', 'wagmi', 'fleek-xyz']) { clearStorageByMatchTerm(item); @@ -141,24 +150,6 @@ export const DynamicProvider: FC = ({ children, graphqlApi [graphqlApiUrl, setAuthToken, setAccessToken, setIsLoggedIn, setUserProfile, setIsNewUser, onAuthenticationSuccess], ); - useEffect(() => { - if (!accessToken) return; - - cookies.set('accessToken', accessToken); - }, [accessToken]); - - useEffect(() => { - if (!authToken) return; - - cookies.set('authToken', authToken); - }, [authToken]); - - useEffect(() => { - if (!projectId) return; - - cookies.set('projectId', projectId); - }, [projectId]); - useEffect(() => { const authToken = cookies.get('authToken'); const accessToken = cookies.get('accessToken'); @@ -184,15 +175,27 @@ export const DynamicProvider: FC = ({ children, graphqlApi useEffect(() => { if (!accessToken || !graphqlApiUrl) return; - // Validates the user session sometime in the future - // if found faulty, it should clear the user session + // Validates the user session sometime in the future. + // If found faulty, it should clear the user session + // e.g. user session clear/logout by dashboard. + // On the other hand, an existing user session can + // persist (localStorage), but dashboard uses cookies + // e.g. user logins in website and expect cross session. + // This is more of a safe-guard due to Dashboard + // having the requirement to clear localStorage items + // that match prefix `fleek-xyz-login`, meaning + // we're only computing if that fails to happen validateUserSession({ accessToken, graphqlApiUrl, projectId, - onAuthenticationFailure: () => typeof triggerLogout === 'function' && triggerLogout(), + onAuthenticationFailure: () => onLogout(), + onAuthenticationSuccess: () => { + cookies.set('accessToken', accessToken); + cookies.set('projectId', projectId); + }, }); - }, [accessToken, graphqlApiUrl, projectId, triggerLogout]); + }, [onLogout]); const settings = { environmentId: dynamicEnvironmentId, diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 9d25fa3..3d958a7 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -5,6 +5,7 @@ import { useConfigStore } from './configStore'; import { getStoreName } from '../utils/store'; import { decodeAccessToken } from '../utils/token'; import type { UserProfile } from '@dynamic-labs/sdk-react-core'; +import { cookies } from '../utils/cookies'; export type TriggerLoginModal = (open: boolean) => void; export type TriggerLogout = () => void; @@ -69,8 +70,15 @@ export const useAuthStore = create()( accessToken, projectId, }); + + cookies.set('accessToken', accessToken); + cookies.set('projectId', projectId); + }, + setAuthToken: (authToken: string) => { + set({ authToken }); + + cookies.set('authToken', authToken); }, - setAuthToken: (authToken: string) => set({ authToken }), setIsLoggingIn: (isLoggingIn: boolean) => set({ isLoggingIn }), setIsLoggedIn: (isLoggedIn: boolean) => set({ isLoggedIn }), reset: () => set(initialState), @@ -100,11 +108,19 @@ export const useAuthStore = create()( throw new Error('Failed to get access token'); } + // TODO: Make a projectId validation against + // the accessToken projectId as it should match. + // On failure, throw error. + + const accessToken = res.data; + set({ - accessToken: res.data, + accessToken, projectId, isLoggingIn: false, }); + + cookies.set('accessToken', accessToken); } catch (err) { console.error('Failed to update access token:', err); diff --git a/src/utils/token.ts b/src/utils/token.ts index 9e50be4..5ac9fe7 100644 --- a/src/utils/token.ts +++ b/src/utils/token.ts @@ -15,3 +15,10 @@ export const decodeAccessToken = (accessToken: string) => { return projectId; }; + +export const truncateMiddle = (str: string, numOfChars = 3, ellipsis = '...'): string => { + const start = str.substring(0, numOfChars); + const end = str.slice(-numOfChars); + + return `${start}${ellipsis}${end}`; +};