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
71 changes: 41 additions & 30 deletions api/auth/verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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.');
}
};
13 changes: 6 additions & 7 deletions public/claim.html
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ <h3>Payment and provisioning are coming next.</h3>
_cardJson:'',
authenticatedAddress:'', authStatus:'NOT_AUTHENTICATED', authChainId:null, siweAuthenticated:false
};
const SIWE_STATEMENT = 'Authenticate with CommandLayer Claim activation.';

// ── HELPERS ───────────────────────────────────────────────────────────────────
function getVerbs() {
Expand Down Expand Up @@ -849,7 +850,7 @@ <h3>Payment and provisioning are coming next.</h3>
const msg=`${domain} wants you to sign in with your Ethereum account:
${address}

Authenticate with CommandLayer Claim activation.
${SIWE_STATEMENT}

URI: ${uri}
Version: 1
Expand All @@ -859,13 +860,11 @@ <h3>Payment and provisioning are coming next.</h3>
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';
Expand Down
25 changes: 24 additions & 1 deletion tests/api-auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,42 @@ 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 () => {
const res = makeRes();
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');
});


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);
assert.equal(res.body.error, 'dependency_unavailable');
});
Loading