From af3c7ee74818aca7536760b42834e47ca67cbe42 Mon Sep 17 00:00:00 2001 From: 0age <37939117+0age@users.noreply.github.com> Date: Thu, 5 Dec 2024 10:57:20 -0800 Subject: [PATCH] fix sessions --- README.md | 18 +- frontend/src/components/SessionManager.tsx | 63 +++---- src/__tests__/routes.test.ts | 54 ++++-- src/__tests__/session.test.ts | 83 +++++++-- src/__tests__/utils/test-server.ts | 100 +++++++++-- src/crypto.ts | 37 ++-- src/database.ts | 15 ++ src/routes.ts | 94 ++++++---- src/schema.ts | 17 ++ src/session.ts | 198 +++++++++------------ 10 files changed, 417 insertions(+), 262 deletions(-) diff --git a/README.md b/README.md index f698807..19663a8 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,10 @@ A minimalistic server-based allocator for [The Compact](https://github.com/Unisw 1. **Get Session Payload** ```http - GET /session/:address + GET /session/:chainId/:address ``` - Returns an EIP-4361 payload for signing. Example response: + Returns an EIP-4361 payload for signing. The chainId parameter specifies which blockchain network to authenticate for. Example response: ```json { @@ -33,11 +33,10 @@ A minimalistic server-based allocator for [The Compact](https://github.com/Unisw "uri": "http://localhost:3000", "statement": "Sign in to Smallocator", "version": "1", - "chainId": 1, + "chainId": 10, "nonce": "unique_nonce", "issuedAt": "2024-12-03T12:00:00Z", - "expirationTime": "2024-12-03T13:00:00Z", - "resources": ["http://localhost:3000/resources"] + "expirationTime": "2024-12-03T13:00:00Z" } } ``` @@ -225,10 +224,10 @@ All compact operations require a valid session ID in the `x-session-id` header. 2. **Get Session Payload** ```http - GET /session/:address + GET /session/:chainId/:address ``` - Returns an EIP-4361 payload for signing. Example response: + Returns an EIP-4361 payload for signing. The chainId parameter specifies which blockchain network to authenticate for. Example response: ```json { @@ -238,11 +237,10 @@ All compact operations require a valid session ID in the `x-session-id` header. "uri": "http://localhost:3000", "statement": "Sign in to Smallocator", "version": "1", - "chainId": 1, + "chainId": 10, "nonce": "unique_nonce", "issuedAt": "2024-12-03T12:00:00Z", - "expirationTime": "2024-12-03T13:00:00Z", - "resources": ["http://localhost:3000/resources"] + "expirationTime": "2024-12-03T13:00:00Z" } } ``` diff --git a/frontend/src/components/SessionManager.tsx b/frontend/src/components/SessionManager.tsx index f0537a0..4e147c7 100644 --- a/frontend/src/components/SessionManager.tsx +++ b/frontend/src/components/SessionManager.tsx @@ -14,34 +14,36 @@ export function SessionManager({ onSessionCreated }: SessionManagerProps) { const createSession = async () => { if (!address || !chainId) return; - const nonce = crypto.randomUUID(); - const baseUrl = process.env.BASE_URL || 'http://localhost:3000'; - const domain = new URL(baseUrl).host; - const resources = [`${baseUrl}/resources`]; - - // Create timestamps once to ensure consistency - const issuedAt = new Date().toISOString(); - const expirationTime = new Date(Date.now() + 30 * 60 * 1000).toISOString(); - try { + // First, get the payload from server + const payloadResponse = await fetch(`/session/${address}`); + if (!payloadResponse.ok) { + throw new Error('Failed to get session payload'); + } + + const { payload } = await payloadResponse.json(); + + // Format the message according to EIP-4361 + const message = [ + `${payload.domain} wants you to sign in with your Ethereum account:`, + payload.address, + '', + payload.statement, + '', + `URI: ${payload.uri}`, + `Version: ${payload.version}`, + `Chain ID: ${payload.chainId}`, + `Nonce: ${payload.nonce}`, + `Issued At: ${payload.issuedAt}`, + `Expiration Time: ${payload.expirationTime}`, + ].join('\n'); + + // Get signature from wallet const signature = await signMessageAsync({ - message: [ - `${domain} wants you to sign in with your Ethereum account:`, - address, - '', - 'Sign in to Smallocator', - '', - `URI: ${baseUrl}`, - `Version: 1`, - `Chain ID: ${chainId}`, - `Nonce: ${nonce}`, - `Issued At: ${issuedAt}`, - `Expiration Time: ${expirationTime}`, - 'Resources:', - ...resources, - ].join('\n'), + message, }); + // Submit signature and payload to create session const response = await fetch('/session', { method: 'POST', headers: { @@ -49,18 +51,7 @@ export function SessionManager({ onSessionCreated }: SessionManagerProps) { }, body: JSON.stringify({ signature, - payload: { - domain, - address, - uri: baseUrl, - statement: 'Sign in to Smallocator', - version: '1', - chainId, - nonce, - issuedAt, - expirationTime, - resources, - }, + payload, }), }); diff --git a/src/__tests__/routes.test.ts b/src/__tests__/routes.test.ts index ee8ed6b..212246f 100644 --- a/src/__tests__/routes.test.ts +++ b/src/__tests__/routes.test.ts @@ -2,12 +2,11 @@ import { FastifyInstance } from 'fastify'; import { createTestServer, validPayload, - getFreshValidPayload, getFreshCompact, cleanupTestServer, compactToAPI, + generateSignature, } from './utils/test-server'; -import { generateSignature } from '../crypto'; import { graphqlClient, AllocatorResponse, @@ -110,11 +109,11 @@ describe('API Routes', () => { }); }); - describe('GET /session/:address', () => { + describe('GET /session/:chainId/:address', () => { it('should return a session payload for valid address', async () => { const response = await server.inject({ method: 'GET', - url: '/session/0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', + url: '/session/1/0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', }); expect(response.statusCode).toBe(200); @@ -129,7 +128,7 @@ describe('API Routes', () => { it('should reject invalid ethereum address', async () => { const response = await server.inject({ method: 'GET', - url: '/session/invalid-address', + url: '/session/1/invalid-address', }); expect(response.statusCode).toBe(400); @@ -138,13 +137,21 @@ describe('API Routes', () => { describe('POST /session', () => { it('should create session with valid signature', async () => { - const payload = getFreshValidPayload(); - const signature = await generateSignature(payload); + // First get a session request + const sessionResponse = await server.inject({ + method: 'GET', + url: `/session/1/${validPayload.address}`, + }); + + expect(sessionResponse.statusCode).toBe(200); + const sessionRequest = JSON.parse(sessionResponse.payload); + + const signature = await generateSignature(sessionRequest.session); const response = await server.inject({ method: 'POST', url: '/session', payload: { - payload, + payload: sessionRequest.session, signature, }, }); @@ -158,11 +165,20 @@ describe('API Routes', () => { }); it('should reject invalid signature', async () => { + // First get a session request + const sessionResponse = await server.inject({ + method: 'GET', + url: `/session/1/${validPayload.address}`, + }); + + expect(sessionResponse.statusCode).toBe(200); + const sessionRequest = JSON.parse(sessionResponse.payload); + const response = await server.inject({ method: 'POST', url: '/session', payload: { - payload: validPayload, + payload: sessionRequest.session, signature: 'invalid-signature', }, }); @@ -175,8 +191,26 @@ describe('API Routes', () => { let sessionId: string; beforeEach(async () => { + // First get a session request + const address = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; + const sessionResponse = await server.inject({ + method: 'GET', + url: `/session/1/${address}`, + }); + + expect(sessionResponse.statusCode).toBe(200); + const sessionRequest = JSON.parse(sessionResponse.payload); + + // Normalize timestamps to match database precision + const payload = { + ...sessionRequest.session, + issuedAt: new Date(sessionRequest.session.issuedAt).toISOString(), + expirationTime: new Date( + sessionRequest.session.expirationTime + ).toISOString(), + }; + // Create a valid session to use in tests - const payload = getFreshValidPayload(); const signature = await generateSignature(payload); const response = await server.inject({ method: 'POST', diff --git a/src/__tests__/session.test.ts b/src/__tests__/session.test.ts index 3fb5fcf..247a391 100644 --- a/src/__tests__/session.test.ts +++ b/src/__tests__/session.test.ts @@ -2,9 +2,9 @@ import { createTestServer, getFreshValidPayload, cleanupTestServer, + generateSignature, } from './utils/test-server'; import type { FastifyInstance } from 'fastify'; -import { generateSignature } from '../crypto'; describe('Session Management', () => { let server: FastifyInstance; @@ -22,7 +22,26 @@ describe('Session Management', () => { describe('Session Creation', () => { it('should create a new session with valid payload', async () => { - const payload = getFreshValidPayload(); + // First get a session request + const address = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; + const sessionResponse = await server.inject({ + method: 'GET', + url: `/session/1/${address}`, + }); + + expect(sessionResponse.statusCode).toBe(200); + const sessionRequest = JSON.parse(sessionResponse.payload); + + // Normalize timestamps to match database precision + const payload = { + ...sessionRequest.session, + issuedAt: new Date(sessionRequest.session.issuedAt).toISOString(), + expirationTime: new Date( + sessionRequest.session.expirationTime + ).toISOString(), + }; + + // Then create the session const signature = await generateSignature(payload); const response = await server.inject({ method: 'POST', @@ -42,11 +61,12 @@ describe('Session Management', () => { }); it('should reject invalid signature', async () => { + const payload = getFreshValidPayload(); const response = await server.inject({ method: 'POST', url: '/session', payload: { - payload: getFreshValidPayload(), + payload, signature: 'invalid-signature', }, }); @@ -55,13 +75,54 @@ describe('Session Management', () => { const result = JSON.parse(response.payload); expect(result).toHaveProperty('error'); }); + + it('should reject when message format does not match payload', async () => { + const payload = getFreshValidPayload(); + const invalidPayload = { + ...payload, + statement: 'Invalid statement', + }; + const signature = await generateSignature(invalidPayload); + + const response = await server.inject({ + method: 'POST', + url: '/session', + payload: { + payload, + signature, + }, + }); + + expect(response.statusCode).toBe(400); + const result = JSON.parse(response.payload); + expect(result).toHaveProperty('error'); + }); }); describe('Session Verification', () => { let sessionId: string; beforeEach(async () => { - const payload = getFreshValidPayload(); + // First get a session request + const address = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; + const sessionResponse = await server.inject({ + method: 'GET', + url: `/session/1/${address}`, + }); + + expect(sessionResponse.statusCode).toBe(200); + const sessionRequest = JSON.parse(sessionResponse.payload); + + // Normalize timestamps to match database precision + const payload = { + ...sessionRequest.session, + issuedAt: new Date(sessionRequest.session.issuedAt).toISOString(), + expirationTime: new Date( + sessionRequest.session.expirationTime + ).toISOString(), + }; + + // Then create the session const signature = await generateSignature(payload); const response = await server.inject({ method: 'POST', @@ -72,24 +133,10 @@ describe('Session Management', () => { }, }); - if (response.statusCode !== 200) { - console.error(`Failed to create session: ${response.payload}`); - } expect(response.statusCode).toBe(200); - const result = JSON.parse(response.payload); - if (!result.session) { - console.error(`Invalid response format: ${JSON.stringify(result)}`); - } expect(result).toHaveProperty('session'); - - if (!result.session?.id) { - console.error( - `Session object missing ID: ${JSON.stringify(result.session)}` - ); - } expect(result.session).toHaveProperty('id'); - sessionId = result.session.id; }); diff --git a/src/__tests__/utils/test-server.ts b/src/__tests__/utils/test-server.ts index 19d1b7f..5f31c6e 100644 --- a/src/__tests__/utils/test-server.ts +++ b/src/__tests__/utils/test-server.ts @@ -4,31 +4,67 @@ import cors from '@fastify/cors'; import { randomUUID } from 'crypto'; import { setupRoutes } from '../../routes'; import { dbManager } from '../setup'; +import { signMessage } from 'viem/accounts'; // Helper to generate test data +const defaultBaseUrl = 'https://smallocator.example'; export const validPayload = { - domain: new URL(process.env.BASE_URL || 'http://localhost:3000').host, + domain: new URL(process.env.BASE_URL || defaultBaseUrl).host, address: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', - uri: process.env.BASE_URL || 'http://localhost:3000', + uri: process.env.BASE_URL || defaultBaseUrl, statement: 'Sign in to Smallocator', version: '1', chainId: 1, - nonce: randomUUID(), // Use UUID for session nonce + nonce: randomUUID(), issuedAt: new Date().toISOString(), - expirationTime: new Date(Date.now() + 3600000).toISOString(), // 1 hour from now - resources: [`${process.env.BASE_URL || 'http://localhost:3000'}/resources`], + expirationTime: new Date(Date.now() + 3600000).toISOString(), }; // Helper to get fresh valid payload with current timestamps export function getFreshValidPayload(): typeof validPayload { + const now = new Date(); + const expirationTime = new Date(now.getTime() + 3600000); return { ...validPayload, - nonce: randomUUID(), // Use UUID for session nonce - issuedAt: new Date().toISOString(), - expirationTime: new Date(Date.now() + 3600000).toISOString(), + nonce: randomUUID(), + issuedAt: now.toISOString(), + expirationTime: expirationTime.toISOString(), }; } +// Helper to format message according to EIP-4361 +export function formatTestMessage(payload: typeof validPayload): string { + return [ + `${payload.domain} wants you to sign in with your Ethereum account:`, + payload.address, + '', + payload.statement, + '', + `URI: ${payload.uri}`, + `Version: ${payload.version}`, + `Chain ID: ${payload.chainId}`, + `Nonce: ${payload.nonce}`, + `Issued At: ${payload.issuedAt}`, + `Expiration Time: ${payload.expirationTime}`, + ].join('\n'); +} + +// Test private key (do not use in production) +const TEST_PRIVATE_KEY = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + +// Helper to generate signature +export async function generateSignature( + payload: typeof validPayload +): Promise { + const message = formatTestMessage(payload); + const signature = await signMessage({ + message, + privateKey: TEST_PRIVATE_KEY as `0x${string}`, + }); + return signature; +} + // Create a test server instance export async function createTestServer(): Promise { const server = fastify({ @@ -48,11 +84,26 @@ export async function createTestServer(): Promise { 'BASE_URL', ], properties: { - SIGNING_ADDRESS: { type: 'string' }, - ALLOCATOR_ADDRESS: { type: 'string' }, - PRIVATE_KEY: { type: 'string' }, - DOMAIN: { type: 'string', default: 'smallocator.example' }, - BASE_URL: { type: 'string', default: 'https://smallocator.example' }, + SIGNING_ADDRESS: { + type: 'string', + default: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', // Address corresponding to TEST_PRIVATE_KEY + }, + ALLOCATOR_ADDRESS: { + type: 'string', + default: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', + }, + PRIVATE_KEY: { + type: 'string', + default: TEST_PRIVATE_KEY, + }, + DOMAIN: { + type: 'string', + default: 'smallocator.example', + }, + BASE_URL: { + type: 'string', + default: 'https://smallocator.example', + }, }, }, dotenv: false, @@ -86,17 +137,34 @@ export async function createTestSession( server: FastifyInstance, address: string = validPayload.address ): Promise { + // First create a session request + const payload = getFreshValidPayload(); + const sessionResponse = await server.inject({ + method: 'GET', + url: `/session/1/${address}`, + }); + + if (sessionResponse.statusCode !== 200) { + throw new Error( + `Failed to create session request: ${sessionResponse.payload}` + ); + } + + const sessionRequest = JSON.parse(sessionResponse.payload); + + // Then create the session + const signature = await generateSignature(payload); const response = await server.inject({ method: 'POST', url: '/session', payload: { - ...validPayload, - address, + payload: sessionRequest.session, + signature, }, }); if (response.statusCode !== 200) { - throw new Error(`Failed to create test session: ${response.payload}`); + throw new Error(`Failed to create session: ${response.payload}`); } const result = JSON.parse(response.payload); diff --git a/src/crypto.ts b/src/crypto.ts index 89e66e4..d5652a6 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -169,23 +169,28 @@ export function verifySigningAddress(configuredAddress: string): void { } export async function generateSignature( - payload: SessionPayload + payload: SessionPayload | string ): Promise { - // Format the message according to EIP-4361 - const message = [ - `${payload.domain} wants you to sign in with your Ethereum account:`, - payload.address, - '', - payload.statement, - '', - `URI: ${payload.uri}`, - `Version: ${payload.version}`, - `Chain ID: ${payload.chainId}`, - `Nonce: ${payload.nonce}`, - `Issued At: ${payload.issuedAt}`, - `Expiration Time: ${payload.expirationTime}`, - payload.resources ? `Resources:\n${payload.resources.join('\n')}` : '', - ].join('\n'); + // If payload is a string, use it directly as the message + const message = + typeof payload === 'string' + ? payload + : [ + `${payload.domain} wants you to sign in with your Ethereum account:`, + payload.address, + '', + payload.statement, + '', + `URI: ${payload.uri}`, + `Version: ${payload.version}`, + `Chain ID: ${payload.chainId}`, + `Nonce: ${payload.nonce}`, + `Issued At: ${payload.issuedAt}`, + `Expiration Time: ${payload.expirationTime}`, + payload.resources + ? `Resources:\n${payload.resources.join('\n')}` + : '', + ].join('\n'); // Sign the message using the private key directly const signature = await signMessage({ diff --git a/src/database.ts b/src/database.ts index a373468..6deb962 100644 --- a/src/database.ts +++ b/src/database.ts @@ -6,6 +6,21 @@ export async function setupDatabase(server: FastifyInstance): Promise { const db = new PGlite(); await initializeDatabase(db); + // Create session_requests table + await db.query(` + CREATE TABLE IF NOT EXISTS session_requests ( + id UUID PRIMARY KEY, + address TEXT NOT NULL, + nonce UUID NOT NULL, + domain TEXT NOT NULL, + chain_id INTEGER NOT NULL, + issued_at TIMESTAMP WITH TIME ZONE NOT NULL, + expiration_time TIMESTAMP WITH TIME ZONE NOT NULL, + used BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ) + `); + // Add the database instance to the server server.decorate('db', db); } diff --git a/src/routes.ts b/src/routes.ts index 90874e1..f53885f 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -117,13 +117,25 @@ export async function setupRoutes(server: FastifyInstance): Promise { // Get session payload server.get( - '/session/:address', + '/session/:chainId/:address', async ( - request: FastifyRequest<{ Params: { address: string } }>, + request: FastifyRequest<{ + Params: { + address: string; + chainId: string; + }; + }>, reply: FastifyReply ): Promise<{ session: SessionPayload } | { error: string }> => { try { - const { address } = request.params as { address: string }; + const { address, chainId } = request.params; + const chainIdNum = parseInt(chainId, 10); + + if (isNaN(chainIdNum)) { + return reply.code(400).send({ + error: 'Invalid chain ID format', + }); + } let normalizedAddress: string; try { @@ -134,37 +146,55 @@ export async function setupRoutes(server: FastifyInstance): Promise { }); } - const nonce = Date.now().toString(); - const baseUrl = process.env.BASE_URL || 'http://localhost:3000'; + const nonce = randomUUID(); + if (!process.env.BASE_URL) { + throw new Error('BASE_URL environment variable must be set'); + } + const baseUrl = process.env.BASE_URL; + const domain = new URL(baseUrl).host; + const issuedAt = new Date(); + const expirationTime = new Date(issuedAt.getTime() + 30 * 60 * 1000); // 30 minutes + const payload = { - domain: new URL(baseUrl).host, + domain, address: normalizedAddress, uri: baseUrl, statement: 'Sign in to Smallocator', version: '1', - chainId: 1, + chainId: chainIdNum, nonce, - issuedAt: new Date().toISOString(), - expirationTime: new Date(Date.now() + 3600000).toISOString(), - resources: [`${baseUrl}/resources`], + issuedAt: issuedAt.toISOString(), + expirationTime: expirationTime.toISOString(), }; - // Store nonce + // Store session request + const requestId = randomUUID(); await server.db.query( - 'INSERT INTO nonces (id, chain_id, nonce) VALUES ($1, $2, $3)', - [randomUUID(), '1', nonce] + `INSERT INTO session_requests ( + id, address, nonce, domain, chain_id, issued_at, expiration_time + ) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + requestId, + normalizedAddress, + nonce, + domain, + chainIdNum, + issuedAt.toISOString(), + expirationTime.toISOString(), + ] ); return reply.code(200).send({ session: payload }); } catch (error) { - return reply.code(400).send({ - error: error instanceof Error ? error.message : 'Invalid address', + server.log.error('Failed to create session request:', error); + return reply.code(500).send({ + error: 'Failed to create session request', }); } } ); - // Create session + // Create new session server.post<{ Body: { signature: string; @@ -183,35 +213,21 @@ export async function setupRoutes(server: FastifyInstance): Promise { > => { try { const { signature, payload } = request.body; - server.log.info( - { - payload, - signatureStart: signature.slice(0, 10), - }, - 'Creating session with payload' - ); + // Validate and create session const session = await validateAndCreateSession( server, signature, payload ); - return { session }; - } catch (err) { - server.log.error( - { - error: err instanceof Error ? err.message : 'Unknown error', - stack: err instanceof Error ? err.stack : undefined, - payload: request.body.payload, - signatureStart: request.body.signature.slice(0, 10), - }, - 'Session creation failed' - ); - reply.code(400); - return { - error: err instanceof Error ? err.message : 'Session creation failed', - }; + return reply.code(200).send({ session }); + } catch (error) { + server.log.error('Session creation failed:', error); + return reply.code(400).send({ + error: + error instanceof Error ? error.message : 'Invalid session request', + }); } } ); @@ -430,7 +446,7 @@ export async function setupRoutes(server: FastifyInstance): Promise { sponsor, chainId, lockId, - response.account.claims.items.map((item) => item.claimHash) + response.account.claims.items.map((claim) => claim.claimHash) ); // Calculate balance available to allocate diff --git a/src/schema.ts b/src/schema.ts index b853ea9..50d6506 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -11,6 +11,19 @@ export const schemas = { domain TEXT NOT NULL ) `, + session_requests: ` + CREATE TABLE IF NOT EXISTS session_requests ( + id TEXT PRIMARY KEY, + address TEXT NOT NULL, + nonce TEXT NOT NULL, + domain TEXT NOT NULL, + chain_id INTEGER NOT NULL, + issued_at TIMESTAMP WITH TIME ZONE NOT NULL, + expiration_time TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + used BOOLEAN DEFAULT FALSE + ) + `, nonces: ` CREATE TABLE IF NOT EXISTS nonces ( id TEXT PRIMARY KEY, @@ -52,6 +65,10 @@ export const indexes = { nonces: [ 'CREATE INDEX IF NOT EXISTS idx_nonces_chain_nonce ON nonces(chain_id, nonce)', ], + session_requests: [ + 'CREATE INDEX IF NOT EXISTS idx_session_requests_address ON session_requests(address)', + 'CREATE INDEX IF NOT EXISTS idx_session_requests_expiration_time ON session_requests(expiration_time)', + ], }; export async function initializeDatabase(db: PGlite): Promise { diff --git a/src/session.ts b/src/session.ts index 4c134a2..10a6f4f 100644 --- a/src/session.ts +++ b/src/session.ts @@ -15,7 +15,7 @@ export interface SessionPayload { nonce: string; issuedAt: string; expirationTime: string; - resources: string[]; + resources?: string[]; } export interface Session { @@ -38,99 +38,91 @@ export async function validateAndCreateSession( throw new Error('Invalid session payload structure'); } - // Verify the nonce exists and hasn't been used - const nonceIsValid = await verifyNonce( - server, - payload.domain, - payload.nonce, - payload.chainId + // Get the original session request + const requests = await server.db.query<{ + issued_at: string; + expiration_time: string; + rows: Array<{ + issued_at: string; + expiration_time: string; + }>; + }>( + `SELECT * FROM session_requests + WHERE nonce = $1 + AND domain = $2 + AND chain_id = $3 + AND address = $4 + AND used = FALSE + AND expiration_time > CURRENT_TIMESTAMP`, + [payload.nonce, payload.domain, payload.chainId, payload.address] ); - if (!nonceIsValid) { - server.log.error( - { - domain: payload.domain, - nonce: payload.nonce, - chainId: payload.chainId, - }, - 'Invalid nonce' - ); - throw new Error('Invalid or expired nonce'); + + if (!requests.rows || requests.rows.length === 0) { + throw new Error('No matching session request found or request expired'); } - // Format and verify the signature + const request = requests.rows[0]; + + // Verify timestamps match + const requestIssuedAt = new Date(request.issued_at).toISOString(); + const requestExpirationTime = new Date( + request.expiration_time + ).toISOString(); + const payloadIssuedAt = new Date(payload.issuedAt).toISOString(); + const payloadExpirationTime = new Date( + payload.expirationTime + ).toISOString(); + + if ( + requestIssuedAt !== payloadIssuedAt || + requestExpirationTime !== payloadExpirationTime + ) { + throw new Error('Session request timestamps do not match'); + } + + // Format message and verify signature const message = formatMessage(payload); - server.log.info( - { - formattedMessage: message, - address: payload.address, - signatureStart: signature.slice(0, 10), - }, - 'Verifying signature' - ); if (!signature.startsWith('0x')) { - server.log.error( - { signatureStart: signature.slice(0, 10) }, - 'Invalid signature format' - ); throw new Error('Invalid signature format: must start with 0x'); } - // Recover the address from the signature and verify it matches try { - const recoveredAddress = await verifyMessage({ + const addressRecovered = await verifyMessage({ address: getAddress(payload.address), message, signature: signature as `0x${string}`, }); - if (!recoveredAddress) { - server.log.error( - { - expectedAddress: getAddress(payload.address), - recoveredAddress, - messageLength: message.length, - messagePreview: message.slice(0, 100), - }, - 'Signature verification failed - no address recovered' - ); - throw new Error( - 'Signature verification failed: recovered address does not match' - ); + if (!addressRecovered) { + throw new Error('Invalid signature'); } - - server.log.info( - { - address: payload.address, - recoveredAddress, - }, - 'Signature verified successfully' - ); } catch (error) { - server.log.error( - { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - address: payload.address, - messageLength: message.length, - messagePreview: message.slice(0, 100), - }, - 'Signature verification error' - ); throw new Error( - `Signature verification failed: ${error instanceof Error ? error.message : String(error)}` + `Invalid signature: ${error instanceof Error ? error.message : String(error)}` ); } // Create session const session: Session = { id: randomUUID(), - address: getAddress(payload.address), + address: payload.address, expiresAt: payload.expirationTime, nonce: payload.nonce, domain: payload.domain, }; + // Mark session request as used + await server.db.query( + `UPDATE session_requests + SET used = TRUE + WHERE nonce = $1 + AND domain = $2 + AND chain_id = $3 + AND address = $4`, + [payload.nonce, payload.domain, payload.chainId, payload.address] + ); + // Store session in database await server.db.query( 'INSERT INTO sessions (id, address, expires_at, nonce, domain) VALUES ($1, $2, $3, $4, $5)', @@ -143,22 +135,9 @@ export async function validateAndCreateSession( ] ); - // Mark nonce as used - await server.db.query( - 'INSERT INTO nonces (id, chain_id, nonce) VALUES ($1, $2, $3)', - [randomUUID(), payload.chainId.toString(), payload.nonce] - ); - return session; } catch (error) { - server.log.error( - { - error: error instanceof Error ? error.message : String(error), - payload, - signature, - }, - 'Session validation failed' - ); + server.log.error('Session validation failed:', error); throw error; } } @@ -216,8 +195,7 @@ function isValidPayload(payload: SessionPayload): boolean { typeof payload.chainId !== 'number' || typeof payload.nonce !== 'string' || typeof payload.issuedAt !== 'string' || - typeof payload.expirationTime !== 'string' || - !Array.isArray(payload.resources) + typeof payload.expirationTime !== 'string' ) { throw new Error('Invalid payload field types'); } @@ -285,51 +263,31 @@ function isValidPayload(payload: SessionPayload): boolean { } // Validate resources URIs if present - for (const resource of payload.resources) { - if (typeof resource !== 'string') { - throw new Error('Invalid resource type'); - } - try { - new URL(resource); - } catch { - throw new Error('Invalid resource URI'); + if (payload.resources) { + for (const resource of payload.resources) { + if (typeof resource !== 'string') { + throw new Error('Invalid resource type'); + } + try { + new URL(resource); + } catch { + throw new Error('Invalid resource URI'); + } } } return true; } catch (error) { console.error('Payload validation failed:', { - error: error instanceof Error ? error.message : String(error), - payload: { - ...payload, - // Exclude sensitive fields if needed - signature: undefined, - }, - env: { - BASE_URL: process.env.BASE_URL, - }, + error: error instanceof Error ? error.message : 'Unknown error', + payload, }); - throw error; + return false; } } -export async function verifyNonce( - server: FastifyInstance, - domain: string, - nonce: string, - chainId: number -): Promise { - // Check if nonce has been used - const result = await server.db.query( - 'SELECT id FROM nonces WHERE chain_id = $1 AND nonce = $2', - [chainId.toString(), nonce] - ); - - return result.rows.length === 0; -} - function formatMessage(payload: SessionPayload): string { - return [ + const lines = [ `${payload.domain} wants you to sign in with your Ethereum account:`, payload.address, '', @@ -341,6 +299,12 @@ function formatMessage(payload: SessionPayload): string { `Nonce: ${payload.nonce}`, `Issued At: ${payload.issuedAt}`, `Expiration Time: ${payload.expirationTime}`, - payload.resources ? `Resources:\n${payload.resources.join('\n')}` : '', - ].join('\n'); + ]; + + if (payload.resources?.length) { + lines.push('Resources:'); + lines.push(...payload.resources.map((r) => `- ${r}`)); + } + + return lines.join('\n'); }