diff --git a/src/__tests__/radius-mcp-sdk.test.ts b/src/__tests__/radius-mcp-sdk.test.ts index a1349bd..95bf50f 100644 --- a/src/__tests__/radius-mcp-sdk.test.ts +++ b/src/__tests__/radius-mcp-sdk.test.ts @@ -560,11 +560,12 @@ describe('Radius MCP SDK', () => { }, })) as EVMAuthErrorResponse; - expect(result.content[0].text).toContain('EVMAUTH_PROOF_MISSING'); + expect(result.content[0].text).toContain('EVMAUTH_SIGNER_MISMATCH'); }); it('should reject proof missing required fields', async () => { - const incompleteProof = { + // Create an incomplete proof using unknown type to avoid 'any' + const incompleteProof: unknown = { challenge: { domain: validProof.challenge.domain, primaryType: validProof.challenge.primaryType, @@ -573,7 +574,7 @@ describe('Radius MCP SDK', () => { // Missing required fields - only including walletAddress and nonce walletAddress: '0x1234567890123456789012345678901234567890', nonce: `${Date.now()}-abcdef1234567890abcdef1234567890`, - } as any, // Type assertion needed since we're intentionally creating an invalid proof + }, }, signature: validProof.signature, }; @@ -773,4 +774,333 @@ describe('Radius MCP SDK', () => { expect(mockBalanceOf).toHaveBeenCalledTimes(2); }); }); + + describe('Input Format Tolerance', () => { + let sdk: RadiusMcpSdk; + + beforeEach(() => { + sdk = new RadiusMcpSdk({ ...config, debug: true }); + mockRecoverTypedDataAddress.mockResolvedValue( + '0x1234567890123456789012345678901234567890' as `0x${string}` + ); + mockBalanceOf.mockResolvedValue(1n); + }); + + describe('JSON String Format', () => { + it('should accept __evmauth as JSON string', async () => { + const handler = vi.fn().mockResolvedValue({ success: true }); + const protectedHandler = sdk.protect(101, handler); + + // Convert proof to JSON string + const stringifiedProof = JSON.stringify(validProof); + + const result = await protectedHandler({ + params: { + name: 'test_tool', + arguments: { + __evmauth: stringifiedProof, // JSON string format + data: 'test' + } + } + }); + + expect(handler).toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + it('should handle nested JSON string serialization', async () => { + const handler = vi.fn().mockResolvedValue({ success: true }); + const protectedHandler = sdk.protect(101, handler); + + // Simulate double-serialized auth (common in proxy scenarios) + const doubleStringified = JSON.stringify(JSON.stringify(validProof)); + + const result = await protectedHandler({ + params: { + name: 'test_tool', + arguments: { + __evmauth: JSON.parse(doubleStringified), // This becomes a string after parsing + data: 'test' + } + } + }); + + expect(handler).toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + it('should provide helpful error for invalid JSON string', async () => { + const handler = vi.fn(); + const protectedHandler = sdk.protect(101, handler); + + const result = await protectedHandler({ + params: { + name: 'test_tool', + arguments: { + __evmauth: '{ invalid json }', // Invalid JSON + data: 'test' + } + } + }); + + expect(handler).not.toHaveBeenCalled(); + const errorResponse = result as EVMAuthErrorResponse; + expect(errorResponse.content[0].text).toContain('EVMAUTH_PROOF_MALFORMED'); + }); + + it('should handle malformed JSON gracefully', async () => { + const handler = vi.fn(); + const protectedHandler = sdk.protect(101, handler); + + const testCases = [ + '{ "challenge": }', // Incomplete JSON + '{ "challenge": null, "signature": }', // Missing value + '{ "challenge": {}, "signature": "invalid" }', // Invalid structure + '', // Empty string + 'null', // JSON null + 'undefined', // Not valid JSON + ]; + + for (const malformedJson of testCases) { + const result = await protectedHandler({ + params: { + name: 'test_tool', + arguments: { + __evmauth: malformedJson, + data: 'test' + } + } + }); + + expect(handler).not.toHaveBeenCalled(); + const errorResponse = result as EVMAuthErrorResponse; + // Different malformed strings produce different errors + const text = errorResponse.content[0].text; + expect( + text.includes('EVMAUTH_PROOF_MALFORMED') || text.includes('EVMAUTH_PROOF_MISSING') + ).toBe(true); + } + }); + }); + + describe('Object Format', () => { + it('should continue to accept __evmauth as parsed object', async () => { + const handler = vi.fn().mockResolvedValue({ success: true }); + const protectedHandler = sdk.protect(101, handler); + + const result = await protectedHandler({ + params: { + name: 'test_tool', + arguments: { + __evmauth: validProof, // Object format (existing behavior) + data: 'test' + } + } + }); + + expect(handler).toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + it('should reject non-object, non-string __evmauth', async () => { + const handler = vi.fn(); + const protectedHandler = sdk.protect(101, handler); + + const invalidTypes = [ + 123, // number + true, // boolean + [], // array + null, // null + undefined, // undefined + ]; + + for (const invalidAuth of invalidTypes) { + const result = await protectedHandler({ + params: { + name: 'test_tool', + arguments: { + __evmauth: invalidAuth, + data: 'test' + } + } + }); + + expect(handler).not.toHaveBeenCalled(); + const errorResponse = result as EVMAuthErrorResponse; + expect(errorResponse.content[0].text).toContain('PROOF_MISSING'); + } + }); + }); + + describe('Mixed Format Compatibility', () => { + it('should work with various MCP client serialization patterns', async () => { + const handler = vi.fn().mockResolvedValue({ success: true }); + const protectedHandler = sdk.protect(101, handler); + + // Test different client patterns + const clientPatterns = [ + // Node.js MCP client (direct object) + { __evmauth: validProof }, + + // Web client (JSON string) + { __evmauth: JSON.stringify(validProof) }, + + // Proxy server (might add extra serialization layer) + { __evmauth: JSON.stringify(validProof) }, + ]; + + for (const [index, args] of clientPatterns.entries()) { + const result = await protectedHandler({ + params: { + name: 'test_tool', + arguments: { ...args, data: `test-${index}` } + } + }); + + expect(handler).toHaveBeenCalledTimes(index + 1); + expect(result).toEqual({ success: true }); + } + }); + }); + + describe('Debug Logging', () => { + it('should log format detection for string inputs', async () => { + const consoleSpy = vi.spyOn(console, 'log'); + const handler = vi.fn().mockResolvedValue({ success: true }); + const protectedHandler = sdk.protect(101, handler); + + await protectedHandler({ + params: { + name: 'test_tool', + arguments: { + __evmauth: JSON.stringify(validProof), + data: 'test' + } + } + }); + + // Should log format detection + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[Radius] Processing stringified proof'), + expect.objectContaining({ + step: 'proof_extraction', + inputFormat: 'json_string', + }) + ); + + // Should log successful parsing + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[Radius] Successfully parsed stringified proof'), + expect.objectContaining({ + wasStringified: true, + success: true, + }) + ); + + consoleSpy.mockRestore(); + }); + + it('should log format detection for object inputs', async () => { + const consoleSpy = vi.spyOn(console, 'log'); + const handler = vi.fn().mockResolvedValue({ success: true }); + const protectedHandler = sdk.protect(101, handler); + + await protectedHandler({ + params: { + name: 'test_tool', + arguments: { + __evmauth: validProof, // Object format + data: 'test' + } + } + }); + + // Should log successful validation with object format + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[Radius] Proof validation successful'), + expect.objectContaining({ + step: 'proof_validation', + success: true, + inputFormat: 'object', + }) + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('Edge Cases', () => { + it('should reject oversized JSON strings', async () => { + const handler = vi.fn(); + const protectedHandler = sdk.protect(101, handler); + + // Create a large JSON string (over 1MB) + const largeData = 'x'.repeat(1024 * 1024); + const oversizedJson = JSON.stringify({ + ...validProof, + extraData: largeData, + }); + + const result = await protectedHandler({ + params: { + name: 'test_tool', + arguments: { + __evmauth: oversizedJson, + data: 'test' + } + } + }); + + expect(handler).not.toHaveBeenCalled(); + const errorResponse = result as EVMAuthErrorResponse; + expect(errorResponse.content[0].text).toContain('EVMAUTH_PROOF_MALFORMED'); + }); + + it('should handle null and undefined in proof fields', async () => { + const handler = vi.fn(); + const protectedHandler = sdk.protect(101, handler); + + const proofWithNulls: unknown = { + challenge: { + domain: null, + message: undefined, + }, + signature: null, + }; + + const result = await protectedHandler({ + params: { + name: 'test_tool', + arguments: { + __evmauth: proofWithNulls, + data: 'test' + } + } + }); + + expect(handler).not.toHaveBeenCalled(); + const errorResponse = result as EVMAuthErrorResponse; + expect(errorResponse.content[0].text).toContain('EVMAUTH_PROOF_MISSING'); + }); + + it('should reject JSON with incorrect brace types', async () => { + const handler = vi.fn(); + const protectedHandler = sdk.protect(101, handler); + + const result = await protectedHandler({ + params: { + name: 'test_tool', + arguments: { + __evmauth: '[1,2,3]', // Array instead of object + data: 'test' + } + } + }); + + expect(handler).not.toHaveBeenCalled(); + const errorResponse = result as EVMAuthErrorResponse; + expect(errorResponse.content[0].text).toContain('EVMAUTH_PROOF_MISSING'); + }); + }); + }); }); diff --git a/src/radius-mcp-sdk.ts b/src/radius-mcp-sdk.ts index c141581..d81954a 100644 --- a/src/radius-mcp-sdk.ts +++ b/src/radius-mcp-sdk.ts @@ -188,7 +188,28 @@ export class RadiusMcpSdk { }); } - const proof = this.extractProof(request); + let proof: EVMAuthProof | null = null; + + try { + proof = this.extractProof(request); + } catch (error) { + if (this.config.debug) { + console.log('[Radius] Auth flow', { + step: 'proof_extraction_failed', + authFlowId, + success: false, + error: error instanceof RadiusError ? error.message : 'Unknown error', + errorCode: error instanceof RadiusError ? error.code : 'UNKNOWN', + }); + } + // If it's a RadiusError with PROOF_MALFORMED, use that code + if (error instanceof RadiusError && error.code === 'PROOF_MALFORMED') { + return this.errorResponse('PROOF_MALFORMED', tokenIds, toolName); + } + // Otherwise treat as missing proof + return this.errorResponse('PROOF_MISSING', tokenIds, toolName); + } + if (!proof) { if (this.config.debug) { console.log('[Radius] Auth flow', { @@ -277,96 +298,174 @@ export class RadiusMcpSdk { }; } + /** + * Type guard to check if an object is an EVMAuthProof + */ + private isEVMAuthProof(obj: unknown): obj is EVMAuthProof { + if (!obj || typeof obj !== 'object') return false; + + const candidate = obj as Record; + + // Check top-level structure + if (typeof candidate.signature !== 'string') return false; + if (!candidate.challenge || typeof candidate.challenge !== 'object') return false; + + const challenge = candidate.challenge as Record; + + // Check challenge structure + if (!challenge.domain || typeof challenge.domain !== 'object') return false; + if (!challenge.message || typeof challenge.message !== 'object') return false; + if (challenge.primaryType !== 'EVMAuthRequest') return false; + if (!challenge.types || typeof challenge.types !== 'object') return false; + + const domain = challenge.domain as Record; + const message = challenge.message as Record; + + // Check domain fields + if (domain.name !== 'EVMAuth') return false; + if (domain.version !== '1') return false; + if (typeof domain.chainId !== 'number') return false; + if (typeof domain.verifyingContract !== 'string') return false; + + // Check message fields + if (typeof message.serverName !== 'string') return false; + if (typeof message.resourceName !== 'string') return false; + if (typeof message.requiredTokens !== 'string') return false; + if (typeof message.walletAddress !== 'string') return false; + if (typeof message.nonce !== 'string') return false; + if (typeof message.issuedAt !== 'string') return false; + if (typeof message.expiresAt !== 'string') return false; + + // Validate signature format + if (!/^0x[0-9a-f]{130}$/i.test(candidate.signature as string)) return false; + + return true; + } + private extractProof(request: MCPRequest): EVMAuthProof | null { const args = request?.params?.arguments; if (!args || typeof args !== 'object') return null; - let auth = (args as Record).__evmauth; - if (!auth) return null; + const authParam = (args as Record).__evmauth; + if (!authParam) return null; + + let parsedAuth: unknown = authParam; + const MAX_JSON_SIZE = 1024 * 1024; // 1MB limit for JSON strings + + // Handle JSON string format (common with web-based MCP clients) + if (typeof authParam === 'string') { + // Check size limit before parsing + if (authParam.length > MAX_JSON_SIZE) { + if (this.config.debug) { + console.log('[Radius] JSON string too large', { + step: 'proof_extraction', + error: 'JSON_TOO_LARGE', + size: authParam.length, + maxSize: MAX_JSON_SIZE, + }); + } + // Return specific error for oversized JSON + throw new RadiusError('PROOF_MALFORMED', 'Authentication JSON too large', { + size: authParam.length, + maxSize: MAX_JSON_SIZE, + }); + } + + // Quick check for JSON-like structure + const trimmed = authParam.trim(); + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) { + if (this.config.debug) { + console.log('[Radius] String does not appear to be JSON', { + step: 'proof_extraction', + inputFormat: 'invalid_string', + firstChar: trimmed[0], + lastChar: trimmed[trimmed.length - 1], + }); + } + return null; + } + + if (this.config.debug) { + console.log('[Radius] Processing stringified proof', { + step: 'proof_extraction', + inputFormat: 'json_string', + stringLength: authParam.length, + stringPreview: authParam.substring(0, 100) + (authParam.length > 100 ? '...' : ''), + }); + } - if (typeof auth === 'string') { try { - auth = JSON.parse(auth); + parsedAuth = JSON.parse(authParam); if (this.config.debug) { - console.log('[Radius] Parsed stringified proof', { + console.log('[Radius] Successfully parsed stringified proof', { step: 'proof_extraction', wasStringified: true, success: true, + resultType: typeof parsedAuth, + hasValidStructure: this.isEVMAuthProof(parsedAuth), }); } } catch (error) { if (this.config.debug) { console.log('[Radius] Failed to parse stringified proof', { step: 'proof_extraction', - error: (error as Error).message, - success: false, + inputFormat: 'invalid_json_string', + error: error instanceof Error ? error.message : 'Unknown error', + troubleshooting: 'Ensure __evmauth is valid JSON when sent as string', }); } - return null; + // Use specific error code for malformed JSON + throw new RadiusError('PROOF_MALFORMED', 'Invalid JSON in authentication proof', { + parseError: error instanceof Error ? error.message : 'Unknown error', + }); } + } else { + parsedAuth = authParam; } - if (typeof auth !== 'object') return null; - - if (this.isValidProof(auth)) { - return auth as EVMAuthProof; - } - - return null; - } - - private isValidProof(obj: unknown): boolean { - if (!obj || typeof obj !== 'object') return false; - - try { - const proof = obj as Record; - - if (!proof.challenge || typeof proof.challenge !== 'object') return false; - if (!proof.signature || typeof proof.signature !== 'string') return false; - - if (!/^0x[0-9a-f]{130}$/i.test(proof.signature)) { - return false; - } - - const challenge = proof.challenge as Record; - - if (!challenge.domain || typeof challenge.domain !== 'object') return false; - if (!challenge.message || typeof challenge.message !== 'object') return false; - if (!challenge.types || typeof challenge.types !== 'object') return false; - if (!challenge.primaryType || typeof challenge.primaryType !== 'string') return false; - - const message = challenge.message as Record; - - const requiredStringFields = [ - 'walletAddress', - 'nonce', - 'issuedAt', - 'expiresAt', - 'resourceName', - 'serverName', - 'requiredTokens', - ]; - - for (const field of requiredStringFields) { - if (typeof message[field] !== 'string') return false; + // Validate using type guard + if (this.isEVMAuthProof(parsedAuth)) { + if (this.config.debug) { + console.log('[Radius] Proof validation successful', { + step: 'proof_validation', + success: true, + inputFormat: typeof authParam === 'string' ? 'json_string' : 'object', + }); } + return parsedAuth; + } - if (!/^0x[0-9a-f]{40}$/i.test(message.walletAddress as string)) { - return false; - } + // Log detailed validation failure + if (this.config.debug) { + const debugInfo: Record = { + step: 'proof_validation', + success: false, + inputFormat: typeof authParam, + }; - const issuedAt = parseInt(message.issuedAt as string, 10); - const expiresAt = parseInt(message.expiresAt as string, 10); - if (Number.isNaN(issuedAt) || Number.isNaN(expiresAt)) { - return false; + if (parsedAuth && typeof parsedAuth === 'object') { + const probe = parsedAuth as Record; + debugInfo.hasSignature = 'signature' in probe; + debugInfo.signatureType = typeof probe.signature; + debugInfo.hasChallenge = 'challenge' in probe; + debugInfo.challengeType = typeof probe.challenge; + + if (probe.challenge && typeof probe.challenge === 'object') { + const challenge = probe.challenge as Record; + debugInfo.hasDomain = 'domain' in challenge; + debugInfo.hasMessage = 'message' in challenge; + debugInfo.primaryType = challenge.primaryType; + } } - return true; - } catch { - return false; + debugInfo.troubleshooting = 'Ensure proof has valid EVMAuth structure with challenge and signature'; + console.log('[Radius] Proof validation failed', debugInfo); } + + return null; } + private async verifyProof( proof: EVMAuthProof, toolName: string, @@ -645,6 +744,7 @@ export class RadiusMcpSdk { PROOF_MISSING: 'You need to authenticate with Radius MCP Server first', PROOF_EXPIRED: 'Your authentication proof has expired. Proofs are valid for 30 seconds.', PROOF_INVALID: 'The proof format is invalid', + PROOF_MALFORMED: 'The authentication proof contains malformed JSON or invalid structure', CHAIN_MISMATCH: 'Wrong blockchain network', CONTRACT_MISMATCH: 'Wrong contract address', SIGNATURE_INVALID: 'Invalid signature', diff --git a/src/types/index.ts b/src/types/index.ts index 60bbc63..d2d86c8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -127,6 +127,7 @@ export type ProofErrorCode = | 'PROOF_MISSING' | 'PROOF_EXPIRED' | 'PROOF_INVALID' + | 'PROOF_MALFORMED' | 'CHAIN_MISMATCH' | 'CONTRACT_MISMATCH' | 'SIGNATURE_INVALID'