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 -->
|
<!-- Hero -->
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="badge">Part of the rSpace Ecosystem</div>
|
<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>
|
<h1><span>Democratic Wallet Management</span><br>for Communities</h1>
|
||||||
<p class="hero-sub">
|
<p class="hero-sub">
|
||||||
Interactive visualizations for <strong>group treasury management</strong>.
|
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>
|
<p>No backend. No tracking. All data fetched client-side from public APIs.</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script src="js/encryptid.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
// ─── EncryptID Auth ────────────────────────────────────────
|
||||||
|
EncryptID.renderAuthButton('encryptid-auth');
|
||||||
|
|
||||||
// ─── Wallet Input Logic ────────────────────────────────────
|
// ─── Wallet Input Logic ────────────────────────────────────
|
||||||
const DEMO_ADDRESS = '0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1';
|
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