feat: add optional EncryptID passkey authentication
Add passkey sign-in to the static wallet explorer: - Add js/encryptid.js with WebAuthn registration/authentication - Add auth button to hero section of index.html - Persists auth state in localStorage - No backend required — all client-side Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d7892f4404
commit
9dc2b01896
|
|
@ -523,6 +523,7 @@
|
|||
<!-- Hero -->
|
||||
<section class="hero">
|
||||
<div class="badge">Part of the rSpace Ecosystem</div>
|
||||
<div id="encryptid-auth" style="margin-bottom:16px;min-height:32px;"></div>
|
||||
<h1><span>Democratic Wallet Management</span><br>for Communities</h1>
|
||||
<p class="hero-sub">
|
||||
Interactive visualizations for <strong>group treasury management</strong>.
|
||||
|
|
@ -750,7 +751,11 @@
|
|||
<p>No backend. No tracking. All data fetched client-side from public APIs.</p>
|
||||
</footer>
|
||||
|
||||
<script src="js/encryptid.js"></script>
|
||||
<script>
|
||||
// ─── EncryptID Auth ────────────────────────────────────────
|
||||
EncryptID.renderAuthButton('encryptid-auth');
|
||||
|
||||
// ─── Wallet Input Logic ────────────────────────────────────
|
||||
const DEMO_ADDRESS = '0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1';
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,245 @@
|
|||
/**
|
||||
* EncryptID Authentication for rWallet.online
|
||||
*
|
||||
* Adds optional passkey-based identity to the static wallet explorer.
|
||||
* When authenticated, the user gets a persistent identity and can
|
||||
* associate wallet addresses with their account.
|
||||
*/
|
||||
|
||||
const EncryptID = (() => {
|
||||
const SERVER = 'https://encryptid.jeffemmett.com';
|
||||
const STORAGE_KEY = 'rwallet_encryptid';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
function toBase64url(buffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function fromBase64url(str) {
|
||||
return Uint8Array.from(
|
||||
atob(str.replace(/-/g, '+').replace(/_/g, '/')),
|
||||
c => c.charCodeAt(0)
|
||||
);
|
||||
}
|
||||
|
||||
function getStoredAuth() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function setStoredAuth(auth) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
|
||||
}
|
||||
|
||||
function clearStoredAuth() {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
// ─── Authentication ──────────────────────────────────────────
|
||||
|
||||
async function authenticate() {
|
||||
// Step 1: Get challenge
|
||||
const startRes = await fetch(`${SERVER}/api/auth/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { options } = await startRes.json();
|
||||
|
||||
// Step 2: WebAuthn ceremony
|
||||
const assertion = await navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge: fromBase64url(options.challenge),
|
||||
rpId: options.rpId,
|
||||
userVerification: options.userVerification,
|
||||
timeout: options.timeout,
|
||||
allowCredentials: options.allowCredentials?.map(c => ({
|
||||
type: c.type,
|
||||
id: fromBase64url(c.id),
|
||||
transports: c.transports,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const response = assertion.response;
|
||||
|
||||
// Step 3: Complete
|
||||
const completeRes = await fetch(`${SERVER}/api/auth/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challenge: options.challenge,
|
||||
credential: {
|
||||
credentialId: assertion.id,
|
||||
authenticatorData: toBase64url(response.authenticatorData),
|
||||
clientDataJSON: toBase64url(response.clientDataJSON),
|
||||
signature: toBase64url(response.signature),
|
||||
userHandle: response.userHandle ? toBase64url(response.userHandle) : null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await completeRes.json();
|
||||
if (!result.success) throw new Error(result.error || 'Authentication failed');
|
||||
|
||||
const auth = { token: result.token, did: result.did, username: result.username };
|
||||
setStoredAuth(auth);
|
||||
return auth;
|
||||
}
|
||||
|
||||
async function register(username) {
|
||||
// Step 1: Get registration options
|
||||
const startRes = await fetch(`${SERVER}/api/register/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, displayName: username }),
|
||||
});
|
||||
const { options, userId } = await startRes.json();
|
||||
|
||||
// Step 2: WebAuthn ceremony
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: {
|
||||
challenge: fromBase64url(options.challenge),
|
||||
rp: options.rp,
|
||||
user: {
|
||||
id: fromBase64url(options.user.id),
|
||||
name: options.user.name,
|
||||
displayName: options.user.displayName,
|
||||
},
|
||||
pubKeyCredParams: options.pubKeyCredParams,
|
||||
authenticatorSelection: options.authenticatorSelection,
|
||||
timeout: options.timeout,
|
||||
attestation: options.attestation,
|
||||
},
|
||||
});
|
||||
|
||||
const response = credential.response;
|
||||
|
||||
// Step 3: Complete
|
||||
const completeRes = await fetch(`${SERVER}/api/register/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challenge: options.challenge,
|
||||
userId,
|
||||
username,
|
||||
credential: {
|
||||
credentialId: credential.id,
|
||||
publicKey: toBase64url(response.getPublicKey?.() || response.attestationObject),
|
||||
attestationObject: toBase64url(response.attestationObject),
|
||||
clientDataJSON: toBase64url(response.clientDataJSON),
|
||||
transports: response.getTransports?.() || [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await completeRes.json();
|
||||
if (!result.success) throw new Error(result.error || 'Registration failed');
|
||||
|
||||
const auth = { token: result.token, did: result.did, username };
|
||||
setStoredAuth(auth);
|
||||
return auth;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearStoredAuth();
|
||||
}
|
||||
|
||||
function isAuthenticated() {
|
||||
return !!getStoredAuth();
|
||||
}
|
||||
|
||||
function getUser() {
|
||||
return getStoredAuth();
|
||||
}
|
||||
|
||||
// ─── UI Component ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render a passkey auth button into the specified container.
|
||||
* Shows sign-in when anonymous, username + sign-out when authenticated.
|
||||
*/
|
||||
function renderAuthButton(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
function render() {
|
||||
const auth = getStoredAuth();
|
||||
|
||||
if (auth) {
|
||||
container.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:10px;justify-content:center;">
|
||||
<span style="color:var(--text-dim);font-size:0.85rem;">
|
||||
Signed in as <strong style="color:var(--primary);">${auth.username || auth.did?.slice(0, 16) + '...'}</strong>
|
||||
</span>
|
||||
<button id="eid-signout" style="background:none;border:1px solid var(--border);color:var(--text-dim);padding:4px 12px;border-radius:6px;cursor:pointer;font-size:0.8rem;">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('eid-signout').addEventListener('click', () => {
|
||||
logout();
|
||||
render();
|
||||
});
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:8px;justify-content:center;">
|
||||
<button id="eid-signin" style="background:none;border:1px solid var(--border);color:var(--text-dim);padding:6px 16px;border-radius:8px;cursor:pointer;font-size:0.85rem;display:flex;align-items:center;gap:6px;transition:all 0.2s;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="10" r="3"/><path d="M12 13v8"/><path d="M9 18h6"/><circle cx="12" cy="10" r="7"/>
|
||||
</svg>
|
||||
Sign in with Passkey
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
const btn = document.getElementById('eid-signin');
|
||||
btn.addEventListener('mouseenter', () => { btn.style.borderColor = 'var(--primary)'; btn.style.color = 'var(--primary)'; });
|
||||
btn.addEventListener('mouseleave', () => { btn.style.borderColor = 'var(--border)'; btn.style.color = 'var(--text-dim)'; });
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.textContent = 'Authenticating...';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await authenticate();
|
||||
render();
|
||||
} catch (e) {
|
||||
if (e.name === 'NotAllowedError') {
|
||||
// No passkey found — prompt to register
|
||||
const name = prompt('No passkey found. Create one?\nEnter a username:');
|
||||
if (name) {
|
||||
try {
|
||||
await register(name.trim());
|
||||
render();
|
||||
} catch (re) {
|
||||
alert('Registration failed: ' + re.message);
|
||||
render();
|
||||
}
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
} else {
|
||||
alert('Sign in failed: ' + e.message);
|
||||
render();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────
|
||||
return {
|
||||
authenticate,
|
||||
register,
|
||||
logout,
|
||||
isAuthenticated,
|
||||
getUser,
|
||||
renderAuthButton,
|
||||
};
|
||||
})();
|
||||
Loading…
Reference in New Issue