From 3c314a904e2a25f9b012017dffe50039003fe617 Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Thu, 21 May 2026 21:45:53 -0400 Subject: [PATCH] Fix SIWE verify policy and claim auth error handling --- api/auth/verify.js | 47 ++++++++++++++++++++++++++++++++------- package.json | 4 +++- public/claim.html | 50 +++++++++++++++++++++++++----------------- tests/api-auth.test.js | 8 +++++++ 4 files changed, 80 insertions(+), 29 deletions(-) diff --git a/api/auth/verify.js b/api/auth/verify.js index 47c6bb1..c154dbb 100644 --- a/api/auth/verify.js +++ b/api/auth/verify.js @@ -2,15 +2,40 @@ const { URL } = require('node:url'); -const ALLOWED_CHAIN_IDS = new Set((process.env.SIWE_ALLOWED_CHAIN_IDS || '1,8453,11155111').split(',').map((v) => Number(v.trim())).filter(Number.isFinite)); -const ALLOWED_DOMAIN = process.env.SIWE_ALLOWED_DOMAIN || ''; -const ALLOWED_URI = process.env.SIWE_ALLOWED_URI || ''; const REQUIRED_STATEMENT = 'CommandLayer Claim activation'; +function isDev() { + return process.env.NODE_ENV === 'development'; +} + function getHost(req) { return String((req.headers && (req.headers['x-forwarded-host'] || req.headers.host)) || '').split(',')[0].trim().toLowerCase(); } +function getAllowedDomain(req) { + const configured = process.env.COMMANDLAYER_SIWE_DOMAIN || process.env.SIWE_ALLOWED_DOMAIN || ''; + if (configured) return configured.toLowerCase(); + const host = getHost(req).split(':')[0]; + if (isDev() && (host === 'localhost' || host === '127.0.0.1')) return host; + return ''; +} + +function getAllowedUri(req) { + const configured = process.env.COMMANDLAYER_SITE_URL || process.env.SIWE_ALLOWED_URI || ''; + if (configured) { + try { return new URL(configured).toString(); } catch { return configured; } + } + const host = getHost(req); + if (isDev() && host.startsWith('localhost')) return `http://${host}/`; + if (isDev() && host.startsWith('127.0.0.1')) return `http://${host}/`; + return ''; +} + +function getAllowedChainIds() { + const raw = process.env.COMMANDLAYER_SIWE_CHAIN_IDS || process.env.SIWE_ALLOWED_CHAIN_IDS || '1,8453'; + return new Set(raw.split(',').map((v) => Number(v.trim())).filter(Number.isFinite)); +} + module.exports = async function handler(req, res) { res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); @@ -36,20 +61,26 @@ module.exports = async function handler(req, res) { try { const parsed = new SiweMessage(message); - const expectedDomain = ALLOWED_DOMAIN || getHost(req); - if (expectedDomain && String(parsed.domain || '').toLowerCase() !== expectedDomain) { + const expectedDomain = getAllowedDomain(req); + const expectedUri = getAllowedUri(req); + const allowedChains = getAllowedChainIds(); + + if (!expectedDomain) { + return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'SIWE domain policy is not configured.' }); + } + if (String(parsed.domain || '').toLowerCase() !== expectedDomain) { return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'SIWE domain mismatch.' }); } - if (ALLOWED_URI && parsed.uri !== ALLOWED_URI) { + if (expectedUri && parsed.uri !== expectedUri) { return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'SIWE URI mismatch.' }); } - if (!ALLOWED_CHAIN_IDS.has(Number(parsed.chainId))) { + if (!allowedChains.has(Number(parsed.chainId))) { return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'Unsupported SIWE chainId.' }); } - if (!String(parsed.statement || '').toLowerCase().includes(REQUIRED_STATEMENT.toLowerCase())) { + if (String(parsed.statement || '').trim() !== REQUIRED_STATEMENT) { return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'Invalid SIWE statement for claim activation.' }); } diff --git a/package.json b/package.json index 60e66fd..67d4c55 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ }, "dependencies": { "ajv": "^8.17.1", - "ajv-formats": "^3.0.1" + "ajv-formats": "^3.0.1", + "ethers": "^6.13.5", + "siwe": "^3.0.0" } } diff --git a/public/claim.html b/public/claim.html index caf0307..b49852e 100644 --- a/public/claim.html +++ b/public/claim.html @@ -784,21 +784,22 @@

Payment and provisioning are coming next.

} async function signInWithEthereum(){ - if(!window.ethereum){ alert('No Ethereum wallet detected. Install a wallet extension first.'); return; } - updateSiweModeHint(); const statusEl=document.getElementById('siweStatus'); const walletEl=document.getElementById('siweWallet'); - statusEl.textContent='Status: Requesting signature...'; - const [address]=await window.ethereum.request({ method:'eth_requestAccounts' }); - const nonceRes=await fetch('/api/auth/nonce',{method:'GET',headers:{'Accept':'application/json'}}); - const nonceData=await nonceRes.json(); - if(!nonceData.ok){ throw new Error('Nonce request failed'); } - const domain=window.location.host; - const uri=window.location.origin; - const chainHex=await window.ethereum.request({ method:'eth_chainId' }); - const chainId=parseInt(chainHex,16); - const issuedAt=new Date().toISOString(); - const msg=`${domain} wants you to sign in with your Ethereum account: + try { + if(!window.ethereum){ throw new Error('NO_WALLET'); } + updateSiweModeHint(); + statusEl.textContent='Status: Requesting signature...'; + const [address]=await window.ethereum.request({ method:'eth_requestAccounts' }); + const nonceRes=await fetch('/api/auth/nonce',{method:'GET',headers:{'Accept':'application/json'}}); + const nonceData=await nonceRes.json(); + if(!nonceData.ok){ throw new Error('NONCE_FAILED'); } + const domain=window.location.host; + const uri=window.location.origin; + const chainHex=await window.ethereum.request({ method:'eth_chainId' }); + const chainId=parseInt(chainHex,16); + const issuedAt=new Date().toISOString(); + const msg=`${domain} wants you to sign in with your Ethereum account: ${address} CommandLayer Claim activation @@ -808,13 +809,22 @@

Payment and provisioning are coming next.

Chain ID: ${chainId} Nonce: ${nonceData.nonce} Issued At: ${issuedAt}`; - const signature=await window.ethereum.request({method:'personal_sign',params:[msg,address]}); - const verifyRes=await fetch('/api/auth/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg,signature})}); - const verify=await verifyRes.json(); - if(!verify.ok){ throw new Error(verify.error||'Authentication failed'); } - state.authenticatedAddress=verify.address; state.authStatus=verify.status; state.authChainId=verify.chainId; - statusEl.textContent='Status: Authenticated'; - walletEl.textContent=`Connected wallet: ${shortAddr(verify.address)}`; + const signature=await window.ethereum.request({method:'personal_sign',params:[msg,address]}); + const verifyRes=await fetch('/api/auth/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg,signature})}); + const verify=await verifyRes.json(); + if(!verify.ok){ throw new Error(verify.error||'Authentication failed'); } + state.authenticatedAddress=verify.address; state.authStatus=verify.status; state.authChainId=verify.chainId; + statusEl.textContent='Status: Authenticated'; + walletEl.textContent=`Connected wallet: ${shortAddr(verify.address)}`; + } catch (err) { + const msg=(err && err.message)||''; + if(msg==='NO_WALLET') statusEl.textContent='Status: No wallet found.'; + else if(msg.includes('User rejected') || msg.includes('rejected')) statusEl.textContent='Status: Wallet rejected signature.'; + else if(msg.includes('dependency unavailable')) statusEl.textContent='Status: Auth dependency unavailable.'; + else if(msg.includes('domain mismatch')) statusEl.textContent='Status: Domain mismatch.'; + else if(msg.includes('signature') || msg.includes('SIWE')) statusEl.textContent='Status: Signature invalid.'; + else statusEl.textContent='Status: Authentication failed.'; + } } function goStep5Auth(){ diff --git a/tests/api-auth.test.js b/tests/api-auth.test.js index 04ebc07..d239118 100644 --- a/tests/api-auth.test.js +++ b/tests/api-auth.test.js @@ -40,3 +40,11 @@ test('POST /api/auth/verify rejects malformed message/signature', async () => { assert.equal(res.body.ok, false); assert.equal(res.body.status, 'AUTH_FAILED'); }); + + +test('POST /api/auth/verify surfaces dependency unavailable when siwe is missing', async () => { + const res = makeRes(); + await verifyHandler({ method: 'POST', body: { message: 'x', signature: '0xy' }, headers: { host: 'localhost:3000' } }, res); + assert.equal(res.statusCode, 503); + assert.match(res.body.error, /dependency unavailable/i); +});