diff --git a/apps/events/src/Boot.tsx b/apps/events/src/Boot.tsx index 3ad5008f..70149a8c 100644 --- a/apps/events/src/Boot.tsx +++ b/apps/events/src/Boot.tsx @@ -1,6 +1,6 @@ import { RouterProvider, createRouter } from '@tanstack/react-router'; -import { Auth } from '@graphprotocol/hypergraph-react'; +import { Hypergraph } from '@graphprotocol/hypergraph-react'; import { PrivyProvider } from '@privy-io/react-auth'; import { routeTree } from './routeTree.gen'; @@ -33,9 +33,9 @@ export function Boot() { }, }} > - + - + ); } diff --git a/apps/events/src/components/logout.tsx b/apps/events/src/components/logout.tsx index ae99657f..e23566c3 100644 --- a/apps/events/src/components/logout.tsx +++ b/apps/events/src/components/logout.tsx @@ -1,10 +1,10 @@ -import { Auth } from '@graphprotocol/hypergraph-react'; +import { Hypergraph } from '@graphprotocol/hypergraph-react'; import { usePrivy } from '@privy-io/react-auth'; import { useRouter } from '@tanstack/react-router'; import { Button } from './ui/button'; export function Logout() { - const { logout: graphLogout } = Auth.useHypergraphAuth(); + const { logout: graphLogout } = Hypergraph.useHypergraphApp(); const { logout: privyLogout } = usePrivy(); const router = useRouter(); const disconnectWallet = async () => { diff --git a/apps/events/src/routes/__root.tsx b/apps/events/src/routes/__root.tsx index 2d8a95ce..48647d8b 100644 --- a/apps/events/src/routes/__root.tsx +++ b/apps/events/src/routes/__root.tsx @@ -1,14 +1,13 @@ import { Logout } from '@/components/logout'; -import { Auth, Hypergraph } from '@graphprotocol/hypergraph-react'; +import { Hypergraph } from '@graphprotocol/hypergraph-react'; import { Link, Outlet, createRootRoute, useLayoutEffect, useRouter } from '@tanstack/react-router'; import { TanStackRouterDevtools } from '@tanstack/router-devtools'; export const Route = createRootRoute({ component: () => { - const { authenticated, getIdentity, getSessionToken } = Auth.useHypergraphAuth(); + const { authenticated, getIdentity } = Hypergraph.useHypergraphApp(); const graphIdentity = getIdentity(); - const loggedInSessionToken = getSessionToken(); const router = useRouter(); @@ -44,20 +43,7 @@ export const Route = createRootRoute({
- {authenticated && graphIdentity && loggedInSessionToken ? ( - - - - ) : ( - - )} + diff --git a/apps/events/src/routes/login.lazy.tsx b/apps/events/src/routes/login.lazy.tsx index 4370d581..3a167b92 100644 --- a/apps/events/src/routes/login.lazy.tsx +++ b/apps/events/src/routes/login.lazy.tsx @@ -1,5 +1,5 @@ import type { Identity } from '@graphprotocol/hypergraph'; -import { Auth } from '@graphprotocol/hypergraph-react'; +import { Hypergraph } from '@graphprotocol/hypergraph-react'; import { usePrivy, useWallets } from '@privy-io/react-auth'; import { createLazyFileRoute, useRouter } from '@tanstack/react-router'; import { Loader2 } from 'lucide-react'; @@ -17,7 +17,7 @@ export const Route = createLazyFileRoute('/login')({ function Login() { const { ready: privyReady, login: privyLogin, signMessage, authenticated: privyAuthenticated } = usePrivy(); const { ready: walletsReady, wallets } = useWallets(); - const { setIdentityAndSessionToken, login: hypergraphLogin } = Auth.useHypergraphAuth(); + const { setIdentityAndSessionToken, login: hypergraphLogin } = Hypergraph.useHypergraphApp(); const { navigate } = useRouter(); const [hypergraphLoginStarted, setHypergraphLoginStarted] = useState(false); diff --git a/packages/hypergraph-react/src/HypergraphAppContext.tsx b/packages/hypergraph-react/src/HypergraphAppContext.tsx index 0fa50ad4..b5973df4 100644 --- a/packages/hypergraph-react/src/HypergraphAppContext.tsx +++ b/packages/hypergraph-react/src/HypergraphAppContext.tsx @@ -7,13 +7,24 @@ import { Identity, Key, Messages, SpaceEvents, type SpaceStorageEntry, Utils, st import { useSelector as useSelectorStore } from '@xstate/store/react'; import { Effect, Exit } from 'effect'; import * as Schema from 'effect/Schema'; -import { createContext, useContext, useEffect, useState } from 'react'; -import { useCallback } from 'react'; -import type { Address } from 'viem'; +import { type ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { SiweMessage } from 'siwe'; +import type { Hex } from 'viem'; +import { type Address, getAddress } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; const decodeResponseMessage = Schema.decodeUnknownEither(Messages.ResponseMessage); export type HypergraphAppCtx = { + // auth related + getSessionToken(): string | null; + getAccountId(): string | null; + getIdentity(): Identity.Identity | null; + authenticated: boolean; + login(signer: Identity.Signer): Promise; + logout(): void; + setIdentityAndSessionToken(account: Identity.Identity & { sessionToken: string }): void; + // app related invitations: Array; createSpace(): Promise; listSpaces(): void; @@ -30,6 +41,19 @@ export type HypergraphAppCtx = { }; export const HypergraphAppContext = createContext({ + getAccountId() { + return null; + }, + getIdentity() { + return null; + }, + getSessionToken() { + return null; + }, + authenticated: false, + async login() {}, + logout() {}, + setIdentityAndSessionToken() {}, invitations: [], async createSpace() { return {}; @@ -57,44 +81,320 @@ export function useHypergraphApp() { return useContext(HypergraphAppContext); } +export function useAuthenticated() { + const ctx = useHypergraphApp(); + return ctx.authenticated; +} +export function useHypergraphAccountId() { + const ctx = useHypergraphApp(); + return ctx.getAccountId(); +} +export function useHypergraphIdentity() { + const ctx = useHypergraphApp(); + return ctx.getIdentity(); +} +export function useHypergraphSessionToken() { + const ctx = useHypergraphApp(); + return ctx.getSessionToken(); +} + export type HypergraphAppProviderProps = Readonly<{ - accountId: string; - syncServer?: string; - sessionToken?: string | null; - encryptionPrivateKey?: string | null; - encryptionPublicKey?: string | null; - signaturePrivateKey?: string | null; - signaturePublicKey?: string | null; - children: React.ReactNode; + storage: Identity.Storage; + syncServerUri?: string; + chainId?: number; + children: ReactNode; }>; +// 1) a) Get session token from local storage, or +// b) Auth with the sync server +// 2) a)Try to get identity from the sync server, or +// b) If identity is not found, create a new identity +// (and store it in the sync server) export function HypergraphAppProvider({ - accountId, - syncServer = 'http://localhost:3030', - sessionToken, - encryptionPrivateKey, - encryptionPublicKey, - signaturePrivateKey, - signaturePublicKey, + storage, + syncServerUri = 'http://localhost:3030', + chainId = 80451, children, }: HypergraphAppProviderProps) { + const [authState, setAuthState] = useState({ + authenticated: false, + accountId: null, + sessionToken: null, + keys: null, + }); const [websocketConnection, setWebsocketConnection] = useState(); const [loading, setLoading] = useState(true); const spaces = useSelectorStore(store, (state) => state.context.spaces); const invitations = useSelectorStore(store, (state) => state.context.invitations); const repo = useSelectorStore(store, (state) => state.context.repo); - const syncServerUrl = new URL(syncServer); - const syncServerWsUrl = new URL(`/?token=${sessionToken}`, syncServerUrl.toString()); - syncServerWsUrl.protocol = 'ws:'; - const syncServerWsUrlString = syncServerWsUrl.toString(); + function prepareSiweMessage(address: Address, nonce: string) { + return new SiweMessage({ + domain: window.location.host, + address, + statement: 'Sign in to Hypergraph', + uri: window.location.origin, + version: '1', + chainId, + nonce, + expirationTime: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(), + }).prepareMessage(); + } + + async function getSessionNonce(accountId: string) { + const nonceReq = { accountId } as const satisfies Messages.RequestLoginNonce; + const res = await fetch(new URL('/login/nonce', syncServerUri), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(nonceReq), + }); + const decoded = Schema.decodeUnknownSync(Messages.ResponseLoginNonce)(await res.json()); + return decoded.sessionNonce; + } + + async function identityExists(accountId: string) { + const res = await fetch(new URL(`/identity?accountId=${accountId}`, syncServerUri), { + method: 'GET', + }); + return res.status === 200; + } + + async function loginWithWallet(signer: Identity.Signer, accountId: Address) { + const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId); + if (!sessionToken) { + const sessionNonce = await getSessionNonce(accountId); + // Use SIWE to login with the server and get a token + const message = prepareSiweMessage(accountId, sessionNonce); + const signature = await signer.signMessage(message); + const loginReq = { accountId, message, signature } as const satisfies Messages.RequestLogin; + const res = await fetch(new URL('/login', syncServerUri), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(loginReq), + }); + const decoded = Schema.decodeUnknownSync(Messages.ResponseLogin)(await res.json()); + Identity.storeAccountId(storage, accountId); + Identity.storeSyncServerSessionToken(storage, accountId, decoded.sessionToken); + } else { + // use whoami to check if the session token is still valid + const res = await fetch(new URL('/whoami', syncServerUri), { + headers: { + Authorization: `Bearer ${sessionToken}`, + }, + }); + if (res.status !== 200 || (await res.text()) !== accountId) { + console.warn('Session token is invalid, wiping state and retrying login with wallet'); + Identity.wipeSyncServerSessionToken(storage, accountId); + return await loginWithWallet(signer, accountId); + } + } + } + + async function loginWithKeys(keys: Identity.IdentityKeys, accountId: Address) { + const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId); + if (sessionToken) { + // use whoami to check if the session token is still valid + const res = await fetch(new URL('/whoami', syncServerUri), { + headers: { + Authorization: `Bearer ${sessionToken}`, + }, + }); + if (res.status !== 200 || (await res.text()) !== accountId) { + console.warn('Session token is invalid, wiping state and retrying login with keys'); + Identity.wipeSyncServerSessionToken(storage, accountId); + return await loginWithKeys(keys, accountId); + } + } else { + const account = privateKeyToAccount(keys.signaturePrivateKey as Hex); + const sessionNonce = await getSessionNonce(account.address); + const message = prepareSiweMessage(account.address, sessionNonce); + const signature = await account.signMessage({ message }); + const req = { + accountId, + message, + publicKey: keys.signaturePublicKey, + signature, + } as const satisfies Messages.RequestLoginWithSigningKey; + const res = await fetch(new URL('/login/with-signing-key', syncServerUri), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(req), + }); + if (res.status !== 200) { + throw new Error('Error logging in with signing key'); + } + const decoded = Schema.decodeUnknownSync(Messages.ResponseLogin)(await res.json()); + Identity.storeAccountId(storage, accountId); + Identity.storeSyncServerSessionToken(storage, accountId, decoded.sessionToken); + } + } + + async function restoreKeys(signer: Identity.Signer, accountId: Address) { + const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId); + if (!sessionToken) { + return; + } + const keys = Identity.loadKeys(storage, accountId); + if (!keys) { + // Try to get the users identity from the sync server + const res = await fetch(new URL('/identity/encrypted', syncServerUri), { + headers: { + Authorization: `Bearer ${sessionToken}`, + }, + }); + if (res.status === 200) { + console.log('Identity found'); + const decoded = Schema.decodeUnknownSync(Messages.ResponseIdentityEncrypted)(await res.json()); + const { keyBox } = decoded; + const { ciphertext, nonce } = keyBox; + const keys = await Identity.decryptIdentity(signer, accountId, ciphertext, nonce); + Identity.storeKeys(storage, accountId, keys); + } else { + throw new Error(`Error fetching identity ${res.status}`); + } + } + } + + async function signup(signer: Identity.Signer, accountId: Address) { + const keys = Identity.createIdentityKeys(); + const { ciphertext, nonce } = await Identity.encryptIdentity(signer, accountId, keys); + const { accountProof, keyProof } = await Identity.proveIdentityOwnership(signer, accountId, keys); + + const account = privateKeyToAccount(keys.signaturePrivateKey as Hex); + const sessionNonce = await getSessionNonce(accountId); + const message = prepareSiweMessage(account.address, sessionNonce); + const signature = await account.signMessage({ message }); + const req = { + keyBox: { accountId, ciphertext, nonce }, + accountProof, + keyProof, + message, + signaturePublicKey: keys.signaturePublicKey, + encryptionPublicKey: keys.encryptionPublicKey, + signature, + } as const satisfies Messages.RequestCreateIdentity; + const res = await fetch(new URL('/identity', syncServerUri), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(req), + }); + if (res.status !== 200) { + // TODO: handle this better? + throw new Error(`Error creating identity: ${res.status}`); + } + const decoded = Schema.decodeUnknownSync(Messages.ResponseCreateIdentity)(await res.json()); + Identity.storeAccountId(storage, accountId); + Identity.storeSyncServerSessionToken(storage, accountId, decoded.sessionToken); + Identity.storeKeys(storage, accountId, keys); + } + + async function login(signer: Identity.Signer) { + if (!signer) { + return; + } + const address = await signer.getAddress(); + if (!address) { + return; + } + const accountId = getAddress(address); + const keys = Identity.loadKeys(storage, accountId); + if (!keys && !(await identityExists(accountId))) { + await signup(signer, accountId); + } else if (keys) { + await loginWithKeys(keys, accountId); + } else { + await loginWithWallet(signer, accountId).then(() => restoreKeys(signer, accountId)); + } + console.log('Identity initialized'); + setAuthState({ + authenticated: true, + accountId, + sessionToken: Identity.loadSyncServerSessionToken(storage, accountId), + keys: Identity.loadKeys(storage, accountId), + }); + store.send({ type: 'reset' }); + } + + function logout() { + websocketConnection?.close(); + setWebsocketConnection(undefined); + + const accountId = Identity.loadAccountId(storage) ?? authState.accountId; + Identity.wipeAccountId(storage); + if (!accountId) { + return; + } + Identity.wipeKeys(storage, accountId); + Identity.wipeSyncServerSessionToken(storage, accountId); + setAuthState({ authenticated: false, accountId: null, sessionToken: null, keys: null }); + } + + const setIdentityAndSessionToken = useCallback( + (account: Identity.Identity & { sessionToken: string }) => { + Identity.storeAccountId(storage, account.accountId); + Identity.storeSyncServerSessionToken(storage, account.accountId, account.sessionToken); + Identity.storeKeys(storage, account.accountId, { + encryptionPublicKey: account.encryptionPublicKey, + encryptionPrivateKey: account.encryptionPrivateKey, + signaturePublicKey: account.signaturePublicKey, + signaturePrivateKey: account.signaturePrivateKey, + }); + store.send({ type: 'reset' }); + setAuthState({ + authenticated: true, + accountId: getAddress(account.accountId), + sessionToken: account.sessionToken, + keys: { + encryptionPublicKey: account.encryptionPublicKey, + encryptionPrivateKey: account.encryptionPrivateKey, + signaturePublicKey: account.signaturePublicKey, + signaturePrivateKey: account.signaturePrivateKey, + }, + }); + console.log('Identity set'); + }, + [storage], + ); + + // check if the user is already authenticated on initial render + const initialRenderAuthCheckRef = useRef(false); + useEffect(() => { + if (!initialRenderAuthCheckRef.current) { + const accountId = Identity.loadAccountId(storage); + if (accountId) { + const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId); + if (sessionToken) { + const keys = Identity.loadKeys(storage, accountId); + if (keys) { + // user is already authenticated, set state + setAuthState({ authenticated: true, accountId: getAddress(accountId), sessionToken, keys }); + } + } + } + // set render auth check to true so next potential rerender doesn't proc this + initialRenderAuthCheckRef.current = true; + } + }, [storage]); // Create a stable WebSocket connection that only depends on accountId useEffect(() => { - if (!sessionToken) { - setLoading(false); + if (!authState.sessionToken) { + setLoading(true); return; } + const syncServerUrl = new URL(syncServerUri); + const syncServerWsUrl = new URL(`/?token=${authState.sessionToken}`, syncServerUrl.toString()); + syncServerWsUrl.protocol = 'ws:'; + const syncServerWsUrlString = syncServerWsUrl.toString(); + const websocketConnection = new WebSocket(syncServerWsUrlString); setWebsocketConnection(websocketConnection); @@ -124,11 +424,16 @@ export function HypergraphAppProvider({ websocketConnection.removeEventListener('close', onClose); websocketConnection.close(); }; - }, [sessionToken, syncServerWsUrlString]); + }, [authState.sessionToken, syncServerUri]); // Handle WebSocket messages in a separate effect useEffect(() => { if (!websocketConnection) return; + const encryptionPrivateKey = authState.keys?.encryptionPrivateKey; + if (!encryptionPrivateKey) { + console.error('No encryption private key found'); + return; + } const onMessage = async (event: MessageEvent) => { const data = Messages.deserialize(event.data); @@ -146,10 +451,6 @@ export function HypergraphAppProvider({ break; } case 'space': { - if (!encryptionPrivateKey) { - console.error('No encryption private key found'); - return; - } let state: SpaceEvents.SpaceState | undefined = undefined; for (const event of response.events) { @@ -328,9 +629,17 @@ export function HypergraphAppProvider({ return () => { websocketConnection.removeEventListener('message', onMessage); }; - }, [websocketConnection, spaces, encryptionPrivateKey]); + }, [websocketConnection, spaces, authState.keys?.encryptionPrivateKey]); const createSpaceForContext = async () => { + const accountId = authState.accountId; + if (!accountId) { + throw new Error('No account id found'); + } + const encryptionPrivateKey = authState.keys?.encryptionPrivateKey; + const encryptionPublicKey = authState.keys?.encryptionPublicKey; + const signaturePrivateKey = authState.keys?.signaturePrivateKey; + const signaturePublicKey = authState.keys?.signaturePublicKey; if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey || !signaturePublicKey) { throw new Error('Missing keys'); } @@ -379,6 +688,14 @@ export function HypergraphAppProvider({ }: Readonly<{ invitation: Messages.Invitation; }>) => { + const accountId = authState.accountId; + if (!accountId) { + throw new Error('No account id found'); + } + const encryptionPrivateKey = authState.keys?.encryptionPrivateKey; + const encryptionPublicKey = authState.keys?.encryptionPublicKey; + const signaturePrivateKey = authState.keys?.signaturePrivateKey; + const signaturePublicKey = authState.keys?.signaturePublicKey; if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey || !signaturePublicKey) { throw new Error('Missing keys'); } @@ -435,7 +752,7 @@ export function HypergraphAppProvider({ signaturePublicKey: identity.signaturePublicKey, }; } - const res = await fetch(`${syncServer}/identity?accountId=${accountId}`); + const res = await fetch(`${syncServerUri}/identity?accountId=${accountId}`); if (res.status !== 200) { throw new Error('Failed to fetch identity'); } @@ -476,6 +793,17 @@ export function HypergraphAppProvider({ accountId: string; }; }>) => { + const accountId = authState.accountId; + if (!accountId) { + throw new Error('No account id found'); + } + const encryptionPrivateKey = authState.keys?.encryptionPrivateKey; + const encryptionPublicKey = authState.keys?.encryptionPublicKey; + const signaturePrivateKey = authState.keys?.signaturePrivateKey; + const signaturePublicKey = authState.keys?.signaturePublicKey; + if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey || !signaturePublicKey) { + throw new Error('Missing keys'); + } if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey || !signaturePublicKey) { throw new Error('Missing keys'); } @@ -528,6 +856,28 @@ export function HypergraphAppProvider({ return ( ); } + +type HypergraphAppState = { + authenticated: boolean; + accountId: Address | null; + sessionToken: string | null; + keys: Identity.IdentityKeys | null; +}; diff --git a/packages/hypergraph-react/src/HypergraphAuthContext.tsx b/packages/hypergraph-react/src/HypergraphAuthContext.tsx deleted file mode 100644 index 149825e8..00000000 --- a/packages/hypergraph-react/src/HypergraphAuthContext.tsx +++ /dev/null @@ -1,372 +0,0 @@ -'use client'; - -import { Identity, Messages, store } from '@graphprotocol/hypergraph'; -import * as Schema from 'effect/Schema'; -import { type ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; -import { SiweMessage } from 'siwe'; -import type { Hex } from 'viem'; -import { type Address, getAddress } from 'viem'; -import { privateKeyToAccount } from 'viem/accounts'; - -export type HypergraphAuthCtx = { - getSessionToken(): string | null; - getAccountId(): string | null; - getIdentity(): Identity.Identity | null; - authenticated: boolean; - login(signer: Identity.Signer): Promise; - logout(): void; - setIdentityAndSessionToken(account: Identity.Identity & { sessionToken: string }): void; -}; - -export const HypergraphAuthContext = createContext({ - getAccountId() { - return null; - }, - getIdentity() { - return null; - }, - getSessionToken() { - return null; - }, - authenticated: false, - async login() {}, - logout() {}, - setIdentityAndSessionToken() {}, -}); - -export function useHypergraphAuth() { - return useContext(HypergraphAuthContext); -} - -export function useAuthenticated() { - const ctx = useHypergraphAuth(); - return ctx.authenticated; -} -export function useHypergraphAccountId() { - const ctx = useHypergraphAuth(); - return ctx.getAccountId(); -} -export function useHypergraphIdentity() { - const ctx = useHypergraphAuth(); - return ctx.getIdentity(); -} -export function useHypergraphSessionToken() { - const ctx = useHypergraphAuth(); - return ctx.getSessionToken(); -} - -export type HypergraphAuthProviderProps = Readonly<{ - storage: Identity.Storage; - syncServerUri?: string; - chainId?: number; - children: ReactNode; -}>; -// 1) a) Get session token from local storage, or -// b) Auth with the sync server -// 2) a)Try to get identity from the sync server, or -// b) If identity is not found, create a new identity -// (and store it in the sync server) -export function HypergraphAuthProvider({ - storage, - syncServerUri = 'http://localhost:3030', - chainId = 80451, - children, -}: HypergraphAuthProviderProps) { - const [authState, setAuthState] = useState({ - authenticated: false, - accountId: null, - sessionToken: null, - keys: null, - }); - - function prepareSiweMessage(address: Address, nonce: string) { - return new SiweMessage({ - domain: window.location.host, - address, - statement: 'Sign in to Hypergraph', - uri: window.location.origin, - version: '1', - chainId, - nonce, - expirationTime: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(), - }).prepareMessage(); - } - - async function getSessionNonce(accountId: string) { - const nonceReq = { accountId } as const satisfies Messages.RequestLoginNonce; - const res = await fetch(new URL('/login/nonce', syncServerUri), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(nonceReq), - }); - const decoded = Schema.decodeUnknownSync(Messages.ResponseLoginNonce)(await res.json()); - return decoded.sessionNonce; - } - - async function identityExists(accountId: string) { - const res = await fetch(new URL(`/identity?accountId=${accountId}`, syncServerUri), { - method: 'GET', - }); - return res.status === 200; - } - - async function loginWithWallet(signer: Identity.Signer, accountId: Address) { - const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId); - if (!sessionToken) { - const sessionNonce = await getSessionNonce(accountId); - // Use SIWE to login with the server and get a token - const message = prepareSiweMessage(accountId, sessionNonce); - const signature = await signer.signMessage(message); - const loginReq = { accountId, message, signature } as const satisfies Messages.RequestLogin; - const res = await fetch(new URL('/login', syncServerUri), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(loginReq), - }); - const decoded = Schema.decodeUnknownSync(Messages.ResponseLogin)(await res.json()); - Identity.storeAccountId(storage, accountId); - Identity.storeSyncServerSessionToken(storage, accountId, decoded.sessionToken); - } else { - // use whoami to check if the session token is still valid - const res = await fetch(new URL('/whoami', syncServerUri), { - headers: { - Authorization: `Bearer ${sessionToken}`, - }, - }); - if (res.status !== 200 || (await res.text()) !== accountId) { - console.warn('Session token is invalid, wiping state and retrying login with wallet'); - Identity.wipeSyncServerSessionToken(storage, accountId); - return await loginWithWallet(signer, accountId); - } - } - } - - async function loginWithKeys(keys: Identity.IdentityKeys, accountId: Address) { - const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId); - if (sessionToken) { - // use whoami to check if the session token is still valid - const res = await fetch(new URL('/whoami', syncServerUri), { - headers: { - Authorization: `Bearer ${sessionToken}`, - }, - }); - if (res.status !== 200 || (await res.text()) !== accountId) { - console.warn('Session token is invalid, wiping state and retrying login with keys'); - Identity.wipeSyncServerSessionToken(storage, accountId); - return await loginWithKeys(keys, accountId); - } - } else { - const account = privateKeyToAccount(keys.signaturePrivateKey as Hex); - const sessionNonce = await getSessionNonce(account.address); - const message = prepareSiweMessage(account.address, sessionNonce); - const signature = await account.signMessage({ message }); - const req = { - accountId, - message, - publicKey: keys.signaturePublicKey, - signature, - } as const satisfies Messages.RequestLoginWithSigningKey; - const res = await fetch(new URL('/login/with-signing-key', syncServerUri), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(req), - }); - if (res.status !== 200) { - throw new Error('Error logging in with signing key'); - } - const decoded = Schema.decodeUnknownSync(Messages.ResponseLogin)(await res.json()); - Identity.storeAccountId(storage, accountId); - Identity.storeSyncServerSessionToken(storage, accountId, decoded.sessionToken); - } - } - - async function restoreKeys(signer: Identity.Signer, accountId: Address) { - const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId); - if (!sessionToken) { - return; - } - const keys = Identity.loadKeys(storage, accountId); - if (!keys) { - // Try to get the users identity from the sync server - const res = await fetch(new URL('/identity/encrypted', syncServerUri), { - headers: { - Authorization: `Bearer ${sessionToken}`, - }, - }); - if (res.status === 200) { - console.log('Identity found'); - const decoded = Schema.decodeUnknownSync(Messages.ResponseIdentityEncrypted)(await res.json()); - const { keyBox } = decoded; - const { ciphertext, nonce } = keyBox; - const keys = await Identity.decryptIdentity(signer, accountId, ciphertext, nonce); - Identity.storeKeys(storage, accountId, keys); - } else { - throw new Error(`Error fetching identity ${res.status}`); - } - } - } - - async function signup(signer: Identity.Signer, accountId: Address) { - const keys = Identity.createIdentityKeys(); - const { ciphertext, nonce } = await Identity.encryptIdentity(signer, accountId, keys); - const { accountProof, keyProof } = await Identity.proveIdentityOwnership(signer, accountId, keys); - - const account = privateKeyToAccount(keys.signaturePrivateKey as Hex); - const sessionNonce = await getSessionNonce(accountId); - const message = prepareSiweMessage(account.address, sessionNonce); - const signature = await account.signMessage({ message }); - const req = { - keyBox: { accountId, ciphertext, nonce }, - accountProof, - keyProof, - message, - signaturePublicKey: keys.signaturePublicKey, - encryptionPublicKey: keys.encryptionPublicKey, - signature, - } as const satisfies Messages.RequestCreateIdentity; - const res = await fetch(new URL('/identity', syncServerUri), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(req), - }); - if (res.status !== 200) { - // TODO: handle this better? - throw new Error(`Error creating identity: ${res.status}`); - } - const decoded = Schema.decodeUnknownSync(Messages.ResponseCreateIdentity)(await res.json()); - Identity.storeAccountId(storage, accountId); - Identity.storeSyncServerSessionToken(storage, accountId, decoded.sessionToken); - Identity.storeKeys(storage, accountId, keys); - } - - async function login(signer: Identity.Signer) { - if (!signer) { - return; - } - const address = await signer.getAddress(); - if (!address) { - return; - } - const accountId = getAddress(address); - const keys = Identity.loadKeys(storage, accountId); - if (!keys && !(await identityExists(accountId))) { - await signup(signer, accountId); - } else if (keys) { - await loginWithKeys(keys, accountId); - } else { - await loginWithWallet(signer, accountId).then(() => restoreKeys(signer, accountId)); - } - console.log('Identity initialized'); - setAuthState({ - authenticated: true, - accountId, - sessionToken: Identity.loadSyncServerSessionToken(storage, accountId), - keys: Identity.loadKeys(storage, accountId), - }); - store.send({ type: 'reset' }); - } - - function logout() { - const accountId = Identity.loadAccountId(storage) ?? authState.accountId; - Identity.wipeAccountId(storage); - if (!accountId) { - return; - } - Identity.wipeKeys(storage, accountId); - Identity.wipeSyncServerSessionToken(storage, accountId); - setAuthState({ authenticated: false, accountId: null, sessionToken: null, keys: null }); - } - - const setIdentityAndSessionToken = useCallback( - (account: Identity.Identity & { sessionToken: string }) => { - Identity.storeAccountId(storage, account.accountId); - Identity.storeSyncServerSessionToken(storage, account.accountId, account.sessionToken); - Identity.storeKeys(storage, account.accountId, { - encryptionPublicKey: account.encryptionPublicKey, - encryptionPrivateKey: account.encryptionPrivateKey, - signaturePublicKey: account.signaturePublicKey, - signaturePrivateKey: account.signaturePrivateKey, - }); - store.send({ type: 'reset' }); - setAuthState({ - authenticated: true, - accountId: getAddress(account.accountId), - sessionToken: account.sessionToken, - keys: { - encryptionPublicKey: account.encryptionPublicKey, - encryptionPrivateKey: account.encryptionPrivateKey, - signaturePublicKey: account.signaturePublicKey, - signaturePrivateKey: account.signaturePrivateKey, - }, - }); - console.log('Identity set'); - }, - [storage], - ); - - // check if the user is already authenticated on initial render - const initialRenderAuthCheckRef = useRef(false); - useEffect(() => { - if (!initialRenderAuthCheckRef.current) { - const accountId = Identity.loadAccountId(storage); - if (accountId) { - const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId); - if (sessionToken) { - const keys = Identity.loadKeys(storage, accountId); - if (keys) { - // user is already authenticated, set state - setAuthState({ authenticated: true, accountId: getAddress(accountId), sessionToken, keys }); - } - } - } - // set render auth check to true so next potential rerender doesn't proc this - initialRenderAuthCheckRef.current = true; - } - }, [storage]); - - return ( - - {children} - - ); -} - -type HypergraphAuthState = { - authenticated: boolean; - accountId: Address | null; - sessionToken: string | null; - keys: Identity.IdentityKeys | null; -}; diff --git a/packages/hypergraph-react/src/index.ts b/packages/hypergraph-react/src/index.ts index c261faaf..b8206a03 100644 --- a/packages/hypergraph-react/src/index.ts +++ b/packages/hypergraph-react/src/index.ts @@ -1,3 +1,2 @@ export * as Hypergraph from './HypergraphAppContext.js'; -export * as Auth from './HypergraphAuthContext.js'; export * as Space from './HypergraphSpaceContext.js'; diff --git a/packages/hypergraph-react/test/HypergraphAuthContext.test.tsx b/packages/hypergraph-react/test/HypergraphAppContext.test.tsx similarity index 74% rename from packages/hypergraph-react/test/HypergraphAuthContext.test.tsx rename to packages/hypergraph-react/test/HypergraphAppContext.test.tsx index a350a033..d94c0597 100644 --- a/packages/hypergraph-react/test/HypergraphAuthContext.test.tsx +++ b/packages/hypergraph-react/test/HypergraphAppContext.test.tsx @@ -4,7 +4,7 @@ import { cleanup, renderHook } from '@testing-library/react'; import React from 'react'; import { afterEach, describe, expect, it } from 'vitest'; -import { HypergraphAuthProvider, useAuthenticated, useHypergraphAccountId } from '../src/HypergraphAuthContext.js'; +import { HypergraphAppProvider, useAuthenticated, useHypergraphAccountId } from '../src/HypergraphAppContext.js'; afterEach(() => { cleanup(); @@ -23,10 +23,10 @@ const storageMock = { }, }; -describe('HypergraphAuthContext', () => { - it('should render the HypergraphAuthProvider and be initially unauthenticetd', async () => { +describe('HypergraphAppContext', () => { + it('should render the HypergraphAppProvider and be initially unauthenticated', async () => { const wrapper = ({ children }: Readonly<{ children: React.ReactNode }>) => ( - {children} + {children} ); const { result: authenticatedResult } = renderHook(() => useAuthenticated(), { wrapper });