Skip to content

Commit a682cb1

Browse files
committed
Add SIWE auth endpoints and claim wallet auth panel
1 parent a9fb49d commit a682cb1

4 files changed

Lines changed: 200 additions & 2 deletions

File tree

api/auth/nonce.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
'use strict';
2+
3+
const crypto = require('node:crypto');
4+
5+
const NONCE_BYTES = 16;
6+
7+
module.exports = async function handler(req, res) {
8+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
9+
res.setHeader('Cache-Control', 'no-store');
10+
11+
if (req.method !== 'GET') {
12+
res.setHeader('Allow', 'GET');
13+
return res.status(405).json({ ok: false, status: 'AUTH_FAILED', error: 'Method not allowed. Use GET.' });
14+
}
15+
16+
const nonce = crypto.randomBytes(NONCE_BYTES).toString('hex');
17+
return res.status(200).json({ ok: true, nonce });
18+
};

api/auth/verify.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
'use strict';
2+
3+
const { URL } = require('node:url');
4+
5+
const ALLOWED_CHAIN_IDS = new Set((process.env.SIWE_ALLOWED_CHAIN_IDS || '1,8453,11155111').split(',').map((v) => Number(v.trim())).filter(Number.isFinite));
6+
const ALLOWED_DOMAIN = process.env.SIWE_ALLOWED_DOMAIN || '';
7+
const ALLOWED_URI = process.env.SIWE_ALLOWED_URI || '';
8+
const REQUIRED_STATEMENT = 'CommandLayer Claim activation';
9+
10+
function getHost(req) {
11+
return String((req.headers && (req.headers['x-forwarded-host'] || req.headers.host)) || '').split(',')[0].trim().toLowerCase();
12+
}
13+
14+
module.exports = async function handler(req, res) {
15+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
16+
res.setHeader('Cache-Control', 'no-store');
17+
18+
if (req.method !== 'POST') {
19+
res.setHeader('Allow', 'POST');
20+
return res.status(405).json({ ok: false, status: 'AUTH_FAILED', error: 'Method not allowed. Use POST.' });
21+
}
22+
23+
const body = req.body || {};
24+
const message = typeof body.message === 'string' ? body.message : '';
25+
const signature = typeof body.signature === 'string' ? body.signature : '';
26+
if (!message || !signature) {
27+
return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'Missing SIWE message or signature.' });
28+
}
29+
30+
let SiweMessage;
31+
try {
32+
({ SiweMessage } = require('siwe'));
33+
} catch {
34+
return res.status(503).json({ ok: false, status: 'AUTH_FAILED', error: 'SIWE verification dependency unavailable on server.' });
35+
}
36+
37+
try {
38+
const parsed = new SiweMessage(message);
39+
const expectedDomain = ALLOWED_DOMAIN || getHost(req);
40+
if (expectedDomain && String(parsed.domain || '').toLowerCase() !== expectedDomain) {
41+
return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'SIWE domain mismatch.' });
42+
}
43+
44+
if (ALLOWED_URI && parsed.uri !== ALLOWED_URI) {
45+
return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'SIWE URI mismatch.' });
46+
}
47+
48+
if (!ALLOWED_CHAIN_IDS.has(Number(parsed.chainId))) {
49+
return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'Unsupported SIWE chainId.' });
50+
}
51+
52+
if (!String(parsed.statement || '').toLowerCase().includes(REQUIRED_STATEMENT.toLowerCase())) {
53+
return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'Invalid SIWE statement for claim activation.' });
54+
}
55+
56+
const result = await parsed.verify({ signature, domain: expectedDomain, nonce: parsed.nonce });
57+
if (!result.success) {
58+
return res.status(401).json({ ok: false, status: 'AUTH_FAILED', error: 'SIWE signature verification failed.' });
59+
}
60+
61+
return res.status(200).json({ ok: true, status: 'AUTHENTICATED', address: result.data.address, chainId: Number(result.data.chainId), ens: null });
62+
} catch (error) {
63+
return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: error && error.message ? error.message : 'Invalid SIWE payload.' });
64+
}
65+
};

public/claim.html

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -428,9 +428,20 @@ <h3>Browser-side key generation</h3>
428428
Wallet connection is required to write records under your ENS name.<br>
429429
Payment/provisioning is required for CommandLayer-native namespace activation.
430430
</div>
431+
432+
<div style="margin-top:16px;padding:14px;border-radius:12px;border:1px solid var(--border);background:#fff">
433+
<div style="font-weight:700;margin-bottom:6px">Sign in with Ethereum</div>
434+
<div style="font-size:13px;color:var(--text-2);margin-bottom:10px">Authenticate the wallet that will submit this activation request.</div>
435+
<div style="font-size:12px;color:var(--muted);margin-bottom:10px">Your Ethereum wallet authenticates the claim request. Your Ed25519 key signs agent receipts.</div>
436+
<button class="btn btn-secondary" id="siweAuthBtn" onclick="signInWithEthereum()">Sign-In with Ethereum</button>
437+
<div id="siweStatus" style="font-size:13px;color:var(--text-2);margin-top:10px">Status: Not authenticated</div>
438+
<div id="siweWallet" style="font-family:var(--mono);font-size:12px;color:var(--muted);margin-top:4px"></div>
439+
<div id="siweModeHint" style="font-size:12px;color:var(--muted);margin-top:8px"></div>
440+
</div>
441+
431442
<div class="btn-row">
432443
<button class="btn btn-ghost" onclick="goToStep(4)">← Back</button>
433-
<button class="btn btn-primary" onclick="goToStep(6)">Continue →</button>
444+
<button class="btn btn-primary" onclick="goStep5Auth()">Continue →</button>
434445
</div>
435446
</div>
436447

@@ -573,7 +584,8 @@ <h3>Payment and provisioning are coming next.</h3>
573584
capMode:'packs', selectedPack:null, cherryVerbs:[],
574585
pubKeyB64:'', privKeyB64:'', kid:'',
575586
keyGenerated:false, keyDownloaded:false, keyAcked:false,
576-
_cardJson:''
587+
_cardJson:'',
588+
authenticatedAddress:'', authStatus:'NOT_AUTHENTICATED', authChainId:null
577589
};
578590

579591
// ── HELPERS ───────────────────────────────────────────────────────────────────
@@ -665,6 +677,7 @@ <h3>Payment and provisioning are coming next.</h3>
665677
document.getElementById('mode-'+id).classList.toggle('selected', id===m);
666678
});
667679
updateNamePreview();
680+
updateSiweModeHint();
668681
}
669682

670683
function updateNamePreview() {
@@ -685,11 +698,13 @@ <h3>Payment and provisioning are coming next.</h3>
685698
if (!state.activationMode) { alert('Please choose an activation mode.'); return; }
686699
// default to CL mode if none selected
687700
if (!document.querySelector('.mode-card.selected')) selectMode('cl');
701+
updateSiweModeHint();
688702
goToStep(3);
689703
}
690704

691705
// Auto-select CL mode on load
692706
selectMode('cl');
707+
updateSiweModeHint();
693708

694709
// ── STEP 3: CAPABILITIES ──────────────────────────────────────────────────────
695710
function setCapMode(m) {
@@ -754,6 +769,63 @@ <h3>Payment and provisioning are coming next.</h3>
754769
goToStep(4);
755770
}
756771

772+
773+
774+
function shortAddr(addr){return addr?`${addr.slice(0,6)}...${addr.slice(-4)}`:'';}
775+
776+
function updateSiweModeHint(){
777+
const el=document.getElementById('siweModeHint');
778+
if(!el) return;
779+
if(state.activationMode==='own' || state.activationMode==='single'){
780+
el.textContent='ENS ownership verification comes after wallet authentication.';
781+
}else{
782+
el.textContent='CommandLayer namespace activation requires wallet authentication before submission.';
783+
}
784+
}
785+
786+
async function signInWithEthereum(){
787+
if(!window.ethereum){ alert('No Ethereum wallet detected. Install a wallet extension first.'); return; }
788+
updateSiweModeHint();
789+
const statusEl=document.getElementById('siweStatus');
790+
const walletEl=document.getElementById('siweWallet');
791+
statusEl.textContent='Status: Requesting signature...';
792+
const [address]=await window.ethereum.request({ method:'eth_requestAccounts' });
793+
const nonceRes=await fetch('/api/auth/nonce',{method:'GET',headers:{'Accept':'application/json'}});
794+
const nonceData=await nonceRes.json();
795+
if(!nonceData.ok){ throw new Error('Nonce request failed'); }
796+
const domain=window.location.host;
797+
const uri=window.location.origin;
798+
const chainHex=await window.ethereum.request({ method:'eth_chainId' });
799+
const chainId=parseInt(chainHex,16);
800+
const issuedAt=new Date().toISOString();
801+
const msg=`${domain} wants you to sign in with your Ethereum account:
802+
${address}
803+
804+
CommandLayer Claim activation
805+
806+
URI: ${uri}
807+
Version: 1
808+
Chain ID: ${chainId}
809+
Nonce: ${nonceData.nonce}
810+
Issued At: ${issuedAt}`;
811+
const signature=await window.ethereum.request({method:'personal_sign',params:[msg,address]});
812+
const verifyRes=await fetch('/api/auth/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg,signature})});
813+
const verify=await verifyRes.json();
814+
if(!verify.ok){ throw new Error(verify.error||'Authentication failed'); }
815+
state.authenticatedAddress=verify.address; state.authStatus=verify.status; state.authChainId=verify.chainId;
816+
statusEl.textContent='Status: Authenticated';
817+
walletEl.textContent=`Connected wallet: ${shortAddr(verify.address)}`;
818+
}
819+
820+
function goStep5Auth(){
821+
updateSiweModeHint();
822+
if(state.activationMode==='cl' && !state.authenticatedAddress){
823+
alert('Sign-In with Ethereum is required before submitting CommandLayer activation request.');
824+
return;
825+
}
826+
goToStep(6);
827+
}
828+
757829
// ── STEP 4: KEY GEN ───────────────────────────────────────────────────────────
758830
async function generateKey() {
759831
const errorBox = document.getElementById('keyError');
@@ -1073,6 +1145,7 @@ <h3>Payment and provisioning are coming next.</h3>
10731145
const err=document.getElementById('keyError'); if(err){err.classList.remove('show');err.textContent='';}
10741146
document.getElementById('namePreview').classList.remove('show');
10751147
selectMode('cl');
1148+
updateSiweModeHint();
10761149
goToStep(1);
10771150
}
10781151

tests/api-auth.test.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
'use strict';
2+
3+
const test = require('node:test');
4+
const assert = require('node:assert/strict');
5+
6+
const nonceHandler = require('../api/auth/nonce');
7+
const verifyHandler = require('../api/auth/verify');
8+
9+
function makeRes() {
10+
return {
11+
statusCode: 200,
12+
headers: {},
13+
body: null,
14+
setHeader(name, value) { this.headers[name.toLowerCase()] = value; },
15+
status(code) { this.statusCode = code; return this; },
16+
json(payload) { this.body = payload; return this; },
17+
};
18+
}
19+
20+
test('GET /api/auth/nonce returns nonce and randomness', async () => {
21+
const r1 = makeRes(); const r2 = makeRes();
22+
await nonceHandler({ method: 'GET', headers: {} }, r1);
23+
await nonceHandler({ method: 'GET', headers: {} }, r2);
24+
assert.equal(r1.statusCode, 200);
25+
assert.equal(r1.body.ok, true);
26+
assert.match(r1.body.nonce, /^[a-f0-9]{32,}$/);
27+
assert.notEqual(r1.body.nonce, r2.body.nonce);
28+
});
29+
30+
test('POST /api/auth/verify rejects missing signature', async () => {
31+
const res = makeRes();
32+
await verifyHandler({ method: 'POST', body: { message: 'x' }, headers: { host: 'localhost:3000' } }, res);
33+
assert.equal(res.statusCode, 400);
34+
assert.equal(res.body.ok, false);
35+
});
36+
37+
test('POST /api/auth/verify rejects malformed message/signature', async () => {
38+
const res = makeRes();
39+
await verifyHandler({ method: 'POST', body: { message: 'invalid', signature: '0xdeadbeef' }, headers: { host: 'localhost:3000' } }, res);
40+
assert.equal(res.body.ok, false);
41+
assert.equal(res.body.status, 'AUTH_FAILED');
42+
});

0 commit comments

Comments
 (0)