diff --git a/api/auth/verify.js b/api/auth/verify.js index 22e3583..ef7ed0b 100644 --- a/api/auth/verify.js +++ b/api/auth/verify.js @@ -13,22 +13,34 @@ function getHost(req) { } function getAllowedDomain(req) { - const configured = process.env.COMMANDLAYER_SIWE_DOMAIN || process.env.SIWE_ALLOWED_DOMAIN || ''; - if (configured) return configured.toLowerCase(); + const configured = process.env.COMMANDLAYER_SIWE_DOMAINS || process.env.COMMANDLAYER_SIWE_DOMAIN || process.env.SIWE_ALLOWED_DOMAIN || ''; + const configuredDomains = configured.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean); + if (configuredDomains.length) return new Set(configuredDomains); const host = getHost(req).split(':')[0]; - if (isDev() && (host === 'localhost' || host === '127.0.0.1')) return host; - return ''; + const defaults = new Set(['www.commandlayer.org']); + if (host === 'commandlayer.org') defaults.add('commandlayer.org'); + if (isDev()) { + defaults.add('localhost'); + defaults.add('127.0.0.1'); + } + return defaults; } 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 configured = process.env.COMMANDLAYER_SITE_URLS || process.env.COMMANDLAYER_SITE_URL || process.env.SIWE_ALLOWED_URI || ''; + const configuredUris = configured.split(',').map((v) => v.trim()).filter(Boolean).map((v) => { + try { return new URL(v).toString(); } catch { return v; } + }); + if (configuredUris.length) return new Set(configuredUris); const host = getHost(req); - if (isDev() && host.startsWith('localhost')) return `http://${host}/`; - if (isDev() && host.startsWith('127.0.0.1')) return `http://${host}/`; - return ''; + const defaults = new Set(['https://www.commandlayer.org/']); + if (host === 'commandlayer.org' || configured.includes('https://commandlayer.org')) { + defaults.add('https://commandlayer.org/'); + } + if (isDev()) { + defaults.add(`http://${host}/`); + } + return defaults; } function getAllowedChainIds() { @@ -37,60 +49,59 @@ function getAllowedChainIds() { } module.exports = async function handler(req, res) { + const fail = (statusCode, error, reason) => res.status(statusCode).json({ ok: false, status: 'AUTH_FAILED', error, reason }); res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); if (req.method !== 'POST') { res.setHeader('Allow', 'POST'); - return res.status(405).json({ ok: false, status: 'AUTH_FAILED', error: 'Method not allowed. Use POST.' }); + return fail(405, 'method_not_allowed', 'Method not allowed. Use POST.'); } const body = req.body || {}; const message = typeof body.message === 'string' ? body.message : ''; const signature = typeof body.signature === 'string' ? body.signature : ''; - if (!message || !signature) { - return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'Missing SIWE message or signature.' }); - } + if (!message) return fail(400, 'missing_message', 'Missing SIWE message.'); + if (!signature) return fail(400, 'missing_signature', 'Missing SIWE signature.'); let SiweMessage; try { ({ SiweMessage } = require('siwe')); } catch { - return res.status(503).json({ ok: false, status: 'AUTH_FAILED', error: 'SIWE verification dependency unavailable on server.' }); + return fail(503, 'dependency_unavailable', 'SIWE verification dependency unavailable on server.'); } try { const parsed = new SiweMessage(message); - const expectedDomain = getAllowedDomain(req); - const expectedUri = getAllowedUri(req); + const allowedDomains = getAllowedDomain(req); + const allowedUris = 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.' }); + const parsedDomain = String(parsed.domain || '').toLowerCase(); + if (!allowedDomains.has(parsedDomain)) { + return fail(400, 'domain_mismatch', `Expected one of ${Array.from(allowedDomains).join(', ')} but received ${parsedDomain || '(empty)'}`); } - if (expectedUri && parsed.uri !== expectedUri) { - return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'SIWE URI mismatch.' }); + const normalizedUri = (() => { try { return new URL(String(parsed.uri || '')).toString(); } catch { return String(parsed.uri || ''); } })(); + if (!allowedUris.has(normalizedUri)) { + return fail(400, 'uri_mismatch', `Expected one of ${Array.from(allowedUris).join(', ')} but received ${normalizedUri || '(empty)'}`); } if (!allowedChains.has(Number(parsed.chainId))) { - return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'Unsupported SIWE chainId.' }); + return fail(400, 'chain_not_allowed', `Unsupported SIWE chainId ${parsed.chainId}.`); } if (String(parsed.statement || '').trim() !== REQUIRED_STATEMENT) { - return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'Invalid SIWE statement for claim activation.' }); + return fail(400, 'statement_mismatch', `Expected statement "${REQUIRED_STATEMENT}" but received "${String(parsed.statement || '')}"`); } - const result = await parsed.verify({ signature, domain: expectedDomain, nonce: parsed.nonce }); + const result = await parsed.verify({ signature, domain: parsedDomain, nonce: parsed.nonce }); if (!result.success) { - return res.status(401).json({ ok: false, status: 'AUTH_FAILED', error: 'SIWE signature verification failed.' }); + return fail(401, 'signature_invalid', 'SIWE signature verification failed.'); } return res.status(200).json({ ok: true, status: 'AUTHENTICATED', address: result.data.address, chainId: Number(result.data.chainId), ens: null }); } catch (error) { - return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: error && error.message ? error.message : 'Invalid SIWE payload.' }); + return fail(400, 'malformed_message', error && error.message ? error.message : 'Invalid SIWE payload.'); } }; diff --git a/public/claim.html b/public/claim.html index b4e04d9..e3415be 100644 --- a/public/claim.html +++ b/public/claim.html @@ -588,6 +588,7 @@

Payment and provisioning are coming next.

_cardJson:'', authenticatedAddress:'', authStatus:'NOT_AUTHENTICATED', authChainId:null, siweAuthenticated:false }; +const SIWE_STATEMENT = 'Authenticate with CommandLayer Claim activation.'; // ── HELPERS ─────────────────────────────────────────────────────────────────── function getVerbs() { @@ -849,7 +850,7 @@

Payment and provisioning are coming next.

const msg=`${domain} wants you to sign in with your Ethereum account: ${address} -Authenticate with CommandLayer Claim activation. +${SIWE_STATEMENT} URI: ${uri} Version: 1 @@ -859,13 +860,11 @@

Payment and provisioning are coming next.

statusEl.textContent='Awaiting wallet signature...'; 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})}); - if(!verifyRes.ok){ throw new Error('SIWE verification failed.'); } const verify=await verifyRes.json(); - if(!verify.ok){ - const errText=String(verify.error||'SIWE verification failed.').toLowerCase(); - if(errText.includes('domain')) throw new Error('Domain mismatch.'); - if(errText.includes('chain')) throw new Error('Unsupported chain.'); - throw new Error('SIWE verification failed.'); + if(!verifyRes.ok || !verify.ok){ + const errorCode=String((verify && verify.error)||'unknown_error'); + const reason=String((verify && verify.reason)||'No additional details provided.'); + throw new Error(`SIWE verification failed: ${errorCode} — ${reason}`); } state.authenticatedAddress=verify.address; state.authStatus=verify.status; state.authChainId=verify.chainId; state.siweAuthenticated=true; statusEl.textContent='Authenticated'; diff --git a/tests/api-auth.test.js b/tests/api-auth.test.js index d239118..322ac84 100644 --- a/tests/api-auth.test.js +++ b/tests/api-auth.test.js @@ -32,6 +32,7 @@ test('POST /api/auth/verify rejects missing signature', async () => { await verifyHandler({ method: 'POST', body: { message: 'x' }, headers: { host: 'localhost:3000' } }, res); assert.equal(res.statusCode, 400); assert.equal(res.body.ok, false); + assert.equal(res.body.error, 'missing_signature'); }); test('POST /api/auth/verify rejects malformed message/signature', async () => { @@ -39,6 +40,28 @@ test('POST /api/auth/verify rejects malformed message/signature', async () => { await verifyHandler({ method: 'POST', body: { message: 'invalid', signature: '0xdeadbeef' }, headers: { host: 'localhost:3000' } }, res); assert.equal(res.body.ok, false); assert.equal(res.body.status, 'AUTH_FAILED'); + assert.ok(['malformed_message', 'dependency_unavailable'].includes(res.body.error)); +}); + +test('POST /api/auth/verify rejects statement mismatch', async () => { + const res = makeRes(); + const message = `www.commandlayer.org wants you to sign in with your Ethereum account: +0x0000000000000000000000000000000000000001 + +Different statement. + +URI: https://www.commandlayer.org +Version: 1 +Chain ID: 1 +Nonce: abcdefgh +Issued At: 2026-01-01T00:00:00.000Z`; + await verifyHandler({ method: 'POST', body: { message, signature: '0xdeadbeef' }, headers: { host: 'www.commandlayer.org' } }, res); + if (res.statusCode === 503) { + assert.equal(res.body.error, 'dependency_unavailable'); + return; + } + assert.equal(res.statusCode, 400); + assert.equal(res.body.error, 'statement_mismatch'); }); @@ -46,5 +69,5 @@ test('POST /api/auth/verify surfaces dependency unavailable when siwe is missing 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); + assert.equal(res.body.error, 'dependency_unavailable'); });