Skip to content

Commit 202a617

Browse files
committed
move loginWithKeys function to core and add tests
1 parent e898cdb commit 202a617

File tree

5 files changed

+208
-68
lines changed

5 files changed

+208
-68
lines changed

apps/server/src/handlers/getIdentity.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { prisma } from '../prisma.js';
22

3-
type Params = {
4-
accountId?: string;
5-
signaturePublicKey?: string;
6-
};
3+
type Params =
4+
| {
5+
accountId: string;
6+
signaturePublicKey?: string;
7+
}
8+
| {
9+
accountId?: string;
10+
signaturePublicKey: string;
11+
};
712

813
export const getIdentity = async (params: Params) => {
914
if (!params.accountId && !params.signaturePublicKey) {

apps/server/src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { parse } from 'node:url';
21
import { Identity, Messages, SpaceEvents, Utils } from '@graphprotocol/hypergraph';
32
import cors from 'cors';
43
import { Effect, Exit, Schema } from 'effect';
54
import express, { type Request, type Response } from 'express';
5+
import { parse } from 'node:url';
66
import { SiweMessage } from 'siwe';
77
import type { Hex } from 'viem';
88
import WebSocket, { WebSocketServer } from 'ws';
@@ -97,7 +97,7 @@ app.post('/login/with-signing-key', async (req, res) => {
9797
try {
9898
const message = Schema.decodeUnknownSync(Messages.RequestLoginWithSigningKey)(req.body);
9999
const accountId = message.accountId;
100-
const nonce = await getSessionNonce({ accountId });
100+
101101
// getIdentity will throw if it doesn't exist
102102
const identity = await getIdentity({ signaturePublicKey: message.publicKey });
103103
if (identity.accountId !== accountId) {
@@ -109,6 +109,7 @@ app.post('/login/with-signing-key', async (req, res) => {
109109
res.status(401).send('Unauthorized');
110110
return;
111111
}
112+
const nonce = await getSessionNonce({ accountId });
112113
const { data: siweMessage } = await siweObject.verify({ signature: message.signature, nonce });
113114
if (!siweMessage.expirationTime) {
114115
res.status(400).send('Expiration time not set');

packages/hypergraph-react/src/HypergraphAppContext.tsx

Lines changed: 2 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { uuid } from '@automerge/automerge';
55
import type { DocHandle } from '@automerge/automerge-repo';
66
import { RepoContext } from '@automerge/automerge-repo-react-hooks';
77
import { Identity, Key, Messages, SpaceEvents, type SpaceStorageEntry, Utils, store } from '@graphprotocol/hypergraph';
8-
import { getSessionNonce, identityExists, prepareSiweMessage } from '@graphprotocol/hypergraph/identity/login';
98
import { useSelector as useSelectorStore } from '@xstate/store/react';
109
import { Effect, Exit } from 'effect';
1110
import * as Schema from 'effect/Schema';
@@ -19,9 +18,7 @@ import {
1918
useRef,
2019
useState,
2120
} from 'react';
22-
import type { Hex } from 'viem';
2321
import { type Address, getAddress } from 'viem';
24-
import { privateKeyToAccount } from 'viem/accounts';
2522

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

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

131-
async function loginWithKeys(keys: Identity.IdentityKeys, accountId: Address, retryCount = 0) {
132-
const sessionToken = Identity.loadSyncServerSessionToken(storage, accountId);
133-
if (sessionToken) {
134-
// use whoami to check if the session token is still valid
135-
const res = await fetch(new URL('/whoami', syncServerUri), {
136-
headers: {
137-
Authorization: `Bearer ${sessionToken}`,
138-
},
139-
});
140-
if (res.status !== 200 || (await res.text()) !== accountId) {
141-
console.warn('Session token is invalid, wiping state and retrying login with keys');
142-
Identity.wipeSyncServerSessionToken(storage, accountId);
143-
if (retryCount > 3) {
144-
throw new Error('Could not login with keys after several attempts');
145-
}
146-
return await loginWithKeys(keys, accountId, retryCount + 1);
147-
}
148-
throw new Error('Could not login with keys');
149-
}
150-
151-
const account = privateKeyToAccount(keys.signaturePrivateKey as Hex);
152-
const sessionNonce = await getSessionNonce(account.address, syncServerUri);
153-
const message = prepareSiweMessage(
154-
account.address,
155-
sessionNonce,
156-
{ host: window.location.host, origin: window.location.origin },
157-
chainId,
158-
);
159-
const signature = await account.signMessage({ message });
160-
const req = {
161-
accountId,
162-
message,
163-
publicKey: keys.signaturePublicKey,
164-
signature,
165-
} as const satisfies Messages.RequestLoginWithSigningKey;
166-
const res = await fetch(new URL('/login/with-signing-key', syncServerUri), {
167-
method: 'POST',
168-
headers: {
169-
'Content-Type': 'application/json',
170-
},
171-
body: JSON.stringify(req),
172-
});
173-
if (res.status !== 200) {
174-
throw new Error('Error logging in with signing key');
175-
}
176-
const decoded = Schema.decodeUnknownSync(Messages.ResponseLogin)(await res.json());
177-
Identity.storeAccountId(storage, accountId);
178-
Identity.storeSyncServerSessionToken(storage, accountId, decoded.sessionToken);
179-
return {
180-
accountId,
181-
sessionToken: decoded.sessionToken,
182-
keys,
183-
};
184-
}
185-
186128
async function login(signer: Identity.Signer) {
187129
if (!signer) {
188130
return;
@@ -202,10 +144,10 @@ export function HypergraphAppProvider({
202144
host: window.location.host,
203145
origin: window.location.origin,
204146
};
205-
if (!keys && !(await identityExists(accountId, syncServerUri))) {
147+
if (!keys && !(await Identity.identityExists(accountId, syncServerUri))) {
206148
authData = await Identity.signup(signer, accountId, syncServerUri, chainId, storage, location);
207149
} else if (keys) {
208-
authData = await loginWithKeys(keys, accountId);
150+
authData = await Identity.loginWithKeys(keys, accountId, syncServerUri, chainId, storage, location);
209151
} else {
210152
authData = await Identity.loginWithWallet(signer, accountId, syncServerUri, chainId, storage, location);
211153
}

packages/hypergraph/src/identity/login.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
import { createIdentityKeys } from './create-identity-keys.js';
1515
import { decryptIdentity, encryptIdentity } from './identity-encryption.js';
1616
import { proveIdentityOwnership } from './prove-ownership.js';
17-
import type { Signer, Storage } from './types.js';
17+
import type { IdentityKeys, Signer, Storage } from './types.js';
1818

1919
export function prepareSiweMessage(
2020
address: Address,
@@ -184,3 +184,65 @@ export async function loginWithWallet(
184184
keys,
185185
};
186186
}
187+
188+
export async function loginWithKeys(
189+
keys: IdentityKeys,
190+
accountId: Address,
191+
syncServerUri: string,
192+
chainId: number,
193+
storage: Storage,
194+
location: { host: string; origin: string },
195+
retryCount = 0,
196+
) {
197+
const sessionToken = loadSyncServerSessionToken(storage, accountId);
198+
if (sessionToken) {
199+
// use whoami to check if the session token is still valid
200+
const res = await fetch(new URL('/whoami', syncServerUri), {
201+
headers: {
202+
Authorization: `Bearer ${sessionToken}`,
203+
},
204+
});
205+
if (res.status !== 200 || (await res.text()) !== accountId) {
206+
console.warn('Session token is invalid, wiping state and retrying login with keys');
207+
wipeSyncServerSessionToken(storage, accountId);
208+
if (retryCount > 3) {
209+
throw new Error('Could not login with keys after several attempts');
210+
}
211+
return await loginWithKeys(keys, accountId, syncServerUri, chainId, storage, location, retryCount + 1);
212+
}
213+
return {
214+
accountId,
215+
sessionToken,
216+
keys,
217+
};
218+
}
219+
220+
const account = privateKeyToAccount(keys.signaturePrivateKey as Hex);
221+
const sessionNonce = await getSessionNonce(accountId, syncServerUri);
222+
const message = prepareSiweMessage(account.address, sessionNonce, location, chainId);
223+
const signature = await account.signMessage({ message });
224+
const req = {
225+
accountId,
226+
message,
227+
publicKey: keys.signaturePublicKey,
228+
signature,
229+
} as const satisfies Messages.RequestLoginWithSigningKey;
230+
const res = await fetch(new URL('/login/with-signing-key', syncServerUri), {
231+
method: 'POST',
232+
headers: {
233+
'Content-Type': 'application/json',
234+
},
235+
body: JSON.stringify(req),
236+
});
237+
if (res.status !== 200) {
238+
throw new Error('Error logging in with signing key');
239+
}
240+
const decoded = Schema.decodeUnknownSync(Messages.ResponseLogin)(await res.json());
241+
storeAccountId(storage, accountId);
242+
storeSyncServerSessionToken(storage, accountId, decoded.sessionToken);
243+
return {
244+
accountId,
245+
sessionToken: decoded.sessionToken,
246+
keys,
247+
};
248+
}

packages/hypergraph/test/identity/login.test.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { privateKeyToAccount } from 'viem/accounts';
22
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
33
import { encryptIdentity } from '../../src/identity/identity-encryption';
4-
import { getSessionNonce, loginWithWallet, prepareSiweMessage, restoreKeys, signup } from '../../src/identity/login';
4+
import {
5+
getSessionNonce,
6+
loginWithKeys,
7+
loginWithWallet,
8+
prepareSiweMessage,
9+
restoreKeys,
10+
signup,
11+
} from '../../src/identity/login';
512
import type { IdentityKeys, Signer, Storage } from '../../src/identity/types';
613
import type * as Messages from '../../src/messages';
714

@@ -446,3 +453,126 @@ describe('loginWithWallet', () => {
446453
expect(result.keys).toEqual(identityKeys);
447454
});
448455
});
456+
457+
describe('loginWithKeys', () => {
458+
let mockStorage: Storage;
459+
const accountId = '0x742d35Cc6634C0532925a3b844Bc454e4438f44e';
460+
const identityKeys: IdentityKeys = {
461+
signaturePublicKey: '0x0262701b2eb1b6b37ad03e24445dfcad1b91309199e43017b657ce2604417c12f5',
462+
signaturePrivateKey: '0x88bb6f20de8dc1787c722dc847f4cf3d00285b8955445f23c483d1237fe85366',
463+
encryptionPrivateKey: '0xbbf164a93b0f78a85346017fa2673cf367c64d81b1c3d6af7ad45e308107a812',
464+
encryptionPublicKey: '0x595e1a6b0bb346d83bc382998943d2e6d9210fd341bc8b9f41a7229eede27240',
465+
};
466+
const location = { host: 'localhost', origin: 'http://localhost' };
467+
468+
beforeEach(() => {
469+
mockStorage = {
470+
getItem: vi.fn(),
471+
setItem: vi.fn(),
472+
removeItem: vi.fn(),
473+
};
474+
});
475+
476+
afterEach(() => {
477+
vi.restoreAllMocks();
478+
});
479+
480+
it('should successfully login with keys and validate the valid session token', async () => {
481+
const sessionToken = 'test-session-token';
482+
483+
// @ts-expect-error
484+
mockStorage.getItem.mockImplementation((key) => {
485+
if (key === `hypergraph:dev:session-token:${accountId}`) {
486+
return sessionToken;
487+
}
488+
489+
return null;
490+
});
491+
492+
vi.spyOn(global, 'fetch').mockImplementation((url) => {
493+
if (url.toString() === 'http://localhost:3000/whoami') {
494+
return Promise.resolve({
495+
status: 200,
496+
text: () => Promise.resolve(accountId),
497+
} as Response);
498+
}
499+
500+
return vi.fn() as never;
501+
});
502+
503+
const result = await loginWithKeys(identityKeys, accountId, 'http://localhost:3000', 1, mockStorage, location);
504+
505+
expect(result.accountId).toBe(accountId);
506+
expect(result.sessionToken).toBe(sessionToken);
507+
expect(result.keys).toEqual(identityKeys);
508+
});
509+
510+
it('should successfully login with keys and retrieve a new session token', async () => {
511+
const sessionToken = 'test-session-token';
512+
513+
vi.spyOn(global, 'fetch').mockImplementation((url) => {
514+
if (url.toString() === 'http://localhost:3000/login/with-signing-key') {
515+
return Promise.resolve({
516+
status: 200,
517+
json: () => Promise.resolve({ sessionToken }),
518+
} as Response);
519+
}
520+
if (url.toString() === 'http://localhost:3000/login/nonce') {
521+
return Promise.resolve({
522+
status: 200,
523+
json: () => Promise.resolve({ sessionNonce: 'Sv2dJppgx9SKDGCIb' }),
524+
} as Response);
525+
}
526+
527+
return vi.fn() as never;
528+
});
529+
530+
const result = await loginWithKeys(identityKeys, accountId, 'http://localhost:3000', 1, mockStorage, location);
531+
532+
expect(result.accountId).toBe(accountId);
533+
expect(result.sessionToken).toBe(sessionToken);
534+
expect(result.keys).toEqual(identityKeys);
535+
});
536+
537+
it('should get new session token if existing token is invalid', async () => {
538+
const sessionToken = 'test-session-token';
539+
540+
// @ts-expect-error
541+
mockStorage.getItem.mockImplementation((key) => {
542+
if (key === `hypergraph:dev:session-token:${accountId}`) {
543+
return sessionToken;
544+
}
545+
546+
return null;
547+
});
548+
549+
vi.spyOn(global, 'fetch').mockImplementation((url) => {
550+
if (url.toString() === 'http://localhost:3000/whoami') {
551+
return Promise.resolve({
552+
status: 200,
553+
text: () => Promise.resolve(accountId),
554+
} as Response);
555+
}
556+
if (url.toString() === 'http://localhost:3000/login/with-signing-key') {
557+
return Promise.resolve({
558+
status: 200,
559+
json: () => Promise.resolve({ sessionToken }),
560+
} as Response);
561+
}
562+
if (url.toString() === 'http://localhost:3000/login/nonce') {
563+
return Promise.resolve({
564+
status: 200,
565+
json: () => Promise.resolve({ sessionNonce: 'Sv2dJppgx9SKDGCIb' }),
566+
} as Response);
567+
}
568+
569+
return vi.fn() as never;
570+
});
571+
572+
const result = await loginWithKeys(identityKeys, accountId, 'http://localhost:3000', 1, mockStorage, location);
573+
574+
expect(result.accountId).toBe(accountId);
575+
expect(result.sessionToken).toBe(sessionToken);
576+
expect(result.keys).toEqual(identityKeys);
577+
});
578+
});

0 commit comments

Comments
 (0)