Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions apps/server/src/handlers/getIdentity.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { prisma } from '../prisma.js';

type Params = {
accountId?: string;
signaturePublicKey?: string;
};
type Params =
| {
accountId: string;
signaturePublicKey?: string;
}
| {
accountId?: string;
signaturePublicKey: string;
};

export const getIdentity = async (params: Params) => {
if (!params.accountId && !params.signaturePublicKey) {
Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { parse } from 'node:url';
import { Identity, Messages, SpaceEvents, Utils } from '@graphprotocol/hypergraph';
import cors from 'cors';
import { Effect, Exit, Schema } from 'effect';
import express, { type Request, type Response } from 'express';
import { parse } from 'node:url';
import { SiweMessage } from 'siwe';
import type { Hex } from 'viem';
import WebSocket, { WebSocketServer } from 'ws';
Expand Down Expand Up @@ -97,7 +97,7 @@ app.post('/login/with-signing-key', async (req, res) => {
try {
const message = Schema.decodeUnknownSync(Messages.RequestLoginWithSigningKey)(req.body);
const accountId = message.accountId;
const nonce = await getSessionNonce({ accountId });

// getIdentity will throw if it doesn't exist
const identity = await getIdentity({ signaturePublicKey: message.publicKey });
if (identity.accountId !== accountId) {
Expand All @@ -109,6 +109,7 @@ app.post('/login/with-signing-key', async (req, res) => {
res.status(401).send('Unauthorized');
return;
}
const nonce = await getSessionNonce({ accountId });
const { data: siweMessage } = await siweObject.verify({ signature: message.signature, nonce });
if (!siweMessage.expirationTime) {
res.status(400).send('Expiration time not set');
Expand Down
62 changes: 2 additions & 60 deletions packages/hypergraph-react/src/HypergraphAppContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { uuid } from '@automerge/automerge';
import type { DocHandle } from '@automerge/automerge-repo';
import { RepoContext } from '@automerge/automerge-repo-react-hooks';
import { Identity, Key, Messages, SpaceEvents, type SpaceStorageEntry, Utils, store } from '@graphprotocol/hypergraph';
import { getSessionNonce, identityExists, prepareSiweMessage } from '@graphprotocol/hypergraph/identity/login';
import { useSelector as useSelectorStore } from '@xstate/store/react';
import { Effect, Exit } from 'effect';
import * as Schema from 'effect/Schema';
Expand All @@ -19,9 +18,7 @@ import {
useRef,
useState,
} from 'react';
import type { Hex } from 'viem';
import { type Address, getAddress } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';

const decodeResponseMessage = Schema.decodeUnknownEither(Messages.ResponseMessage);

Expand Down Expand Up @@ -128,61 +125,6 @@ export function HypergraphAppProvider({
const sessionToken = useSelectorStore(store, (state) => state.context.sessionToken);
const keys = useSelectorStore(store, (state) => state.context.keys);

async function loginWithKeys(keys: Identity.IdentityKeys, accountId: Address, retryCount = 0) {
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);
if (retryCount > 3) {
throw new Error('Could not login with keys after several attempts');
}
return await loginWithKeys(keys, accountId, retryCount + 1);
}
throw new Error('Could not login with keys');
}

const account = privateKeyToAccount(keys.signaturePrivateKey as Hex);
const sessionNonce = await getSessionNonce(account.address, syncServerUri);
const message = prepareSiweMessage(
account.address,
sessionNonce,
{ host: window.location.host, origin: window.location.origin },
chainId,
);
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);
return {
accountId,
sessionToken: decoded.sessionToken,
keys,
};
}

async function login(signer: Identity.Signer) {
if (!signer) {
return;
Expand All @@ -202,10 +144,10 @@ export function HypergraphAppProvider({
host: window.location.host,
origin: window.location.origin,
};
if (!keys && !(await identityExists(accountId, syncServerUri))) {
if (!keys && !(await Identity.identityExists(accountId, syncServerUri))) {
authData = await Identity.signup(signer, accountId, syncServerUri, chainId, storage, location);
} else if (keys) {
authData = await loginWithKeys(keys, accountId);
authData = await Identity.loginWithKeys(keys, accountId, syncServerUri, chainId, storage, location);
} else {
authData = await Identity.loginWithWallet(signer, accountId, syncServerUri, chainId, storage, location);
}
Expand Down
64 changes: 63 additions & 1 deletion packages/hypergraph/src/identity/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
import { createIdentityKeys } from './create-identity-keys.js';
import { decryptIdentity, encryptIdentity } from './identity-encryption.js';
import { proveIdentityOwnership } from './prove-ownership.js';
import type { Signer, Storage } from './types.js';
import type { IdentityKeys, Signer, Storage } from './types.js';

export function prepareSiweMessage(
address: Address,
Expand Down Expand Up @@ -184,3 +184,65 @@ export async function loginWithWallet(
keys,
};
}

export async function loginWithKeys(
keys: IdentityKeys,
accountId: Address,
syncServerUri: string,
chainId: number,
storage: Storage,
location: { host: string; origin: string },
retryCount = 0,
) {
const sessionToken = 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');
wipeSyncServerSessionToken(storage, accountId);
if (retryCount > 3) {
throw new Error('Could not login with keys after several attempts');
}
return await loginWithKeys(keys, accountId, syncServerUri, chainId, storage, location, retryCount + 1);
}
return {
accountId,
sessionToken,
keys,
};
}

const account = privateKeyToAccount(keys.signaturePrivateKey as Hex);
const sessionNonce = await getSessionNonce(accountId, syncServerUri);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch here, I hadn't realized we were using the wrong account ID

const message = prepareSiweMessage(account.address, sessionNonce, location, chainId);
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());
storeAccountId(storage, accountId);
storeSyncServerSessionToken(storage, accountId, decoded.sessionToken);
return {
accountId,
sessionToken: decoded.sessionToken,
keys,
};
}
132 changes: 131 additions & 1 deletion packages/hypergraph/test/identity/login.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { privateKeyToAccount } from 'viem/accounts';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { encryptIdentity } from '../../src/identity/identity-encryption';
import { getSessionNonce, loginWithWallet, prepareSiweMessage, restoreKeys, signup } from '../../src/identity/login';
import {
getSessionNonce,
loginWithKeys,
loginWithWallet,
prepareSiweMessage,
restoreKeys,
signup,
} from '../../src/identity/login';
import type { IdentityKeys, Signer, Storage } from '../../src/identity/types';
import type * as Messages from '../../src/messages';

Expand Down Expand Up @@ -446,3 +453,126 @@ describe('loginWithWallet', () => {
expect(result.keys).toEqual(identityKeys);
});
});

describe('loginWithKeys', () => {
let mockStorage: Storage;
const accountId = '0x742d35Cc6634C0532925a3b844Bc454e4438f44e';
const identityKeys: IdentityKeys = {
signaturePublicKey: '0x0262701b2eb1b6b37ad03e24445dfcad1b91309199e43017b657ce2604417c12f5',
signaturePrivateKey: '0x88bb6f20de8dc1787c722dc847f4cf3d00285b8955445f23c483d1237fe85366',
encryptionPrivateKey: '0xbbf164a93b0f78a85346017fa2673cf367c64d81b1c3d6af7ad45e308107a812',
encryptionPublicKey: '0x595e1a6b0bb346d83bc382998943d2e6d9210fd341bc8b9f41a7229eede27240',
};
const location = { host: 'localhost', origin: 'http://localhost' };

beforeEach(() => {
mockStorage = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
};
});

afterEach(() => {
vi.restoreAllMocks();
});

it('should successfully login with keys and validate the valid session token', async () => {
const sessionToken = 'test-session-token';

// @ts-expect-error
mockStorage.getItem.mockImplementation((key) => {
if (key === `hypergraph:dev:session-token:${accountId}`) {
return sessionToken;
}

return null;
});

vi.spyOn(global, 'fetch').mockImplementation((url) => {
if (url.toString() === 'http://localhost:3000/whoami') {
return Promise.resolve({
status: 200,
text: () => Promise.resolve(accountId),
} as Response);
}

return vi.fn() as never;
});

const result = await loginWithKeys(identityKeys, accountId, 'http://localhost:3000', 1, mockStorage, location);

expect(result.accountId).toBe(accountId);
expect(result.sessionToken).toBe(sessionToken);
expect(result.keys).toEqual(identityKeys);
});

it('should successfully login with keys and retrieve a new session token', async () => {
const sessionToken = 'test-session-token';

vi.spyOn(global, 'fetch').mockImplementation((url) => {
if (url.toString() === 'http://localhost:3000/login/with-signing-key') {
return Promise.resolve({
status: 200,
json: () => Promise.resolve({ sessionToken }),
} as Response);
}
if (url.toString() === 'http://localhost:3000/login/nonce') {
return Promise.resolve({
status: 200,
json: () => Promise.resolve({ sessionNonce: 'Sv2dJppgx9SKDGCIb' }),
} as Response);
}

return vi.fn() as never;
});

const result = await loginWithKeys(identityKeys, accountId, 'http://localhost:3000', 1, mockStorage, location);

expect(result.accountId).toBe(accountId);
expect(result.sessionToken).toBe(sessionToken);
expect(result.keys).toEqual(identityKeys);
});

it('should get new session token if existing token is invalid', async () => {
const sessionToken = 'test-session-token';

// @ts-expect-error
mockStorage.getItem.mockImplementation((key) => {
if (key === `hypergraph:dev:session-token:${accountId}`) {
return sessionToken;
}

return null;
});

vi.spyOn(global, 'fetch').mockImplementation((url) => {
if (url.toString() === 'http://localhost:3000/whoami') {
return Promise.resolve({
status: 200,
text: () => Promise.resolve(accountId),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this actually testing with a valid existing token? Shouldn't this return a different account ID or an error to test the case of an invalid token? (Unless I'm missing something)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spot on! fixed the test

} as Response);
}
if (url.toString() === 'http://localhost:3000/login/with-signing-key') {
return Promise.resolve({
status: 200,
json: () => Promise.resolve({ sessionToken }),
} as Response);
}
if (url.toString() === 'http://localhost:3000/login/nonce') {
return Promise.resolve({
status: 200,
json: () => Promise.resolve({ sessionNonce: 'Sv2dJppgx9SKDGCIb' }),
} as Response);
}

return vi.fn() as never;
});

const result = await loginWithKeys(identityKeys, accountId, 'http://localhost:3000', 1, mockStorage, location);

expect(result.accountId).toBe(accountId);
expect(result.sessionToken).toBe(sessionToken);
expect(result.keys).toEqual(identityKeys);
});
});
Loading