Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions api/auth/verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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.' });
}

Expand Down
50 changes: 30 additions & 20 deletions public/claim.html
Original file line number Diff line number Diff line change
Expand Up @@ -784,21 +784,22 @@ <h3>Payment and provisioning are coming next.</h3>
}

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
Expand All @@ -808,13 +809,22 @@ <h3>Payment and provisioning are coming next.</h3>
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(){
Expand Down
8 changes: 8 additions & 0 deletions tests/api-auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading