feat(rwallet): link external wallets via EIP-6963 + SIWE

Users can now connect browser wallets (MetaMask, Rainbow, etc.) to their
EncryptID identity via SIWE ownership proof, and view linked wallet
balances in the unified rWallet viewer.

New files:
- eip6963.ts: EIP-6963 multi-provider discovery
- external-signer.ts: EIP-1193 provider wrapper for tx signing
- linked-wallets.ts: encrypted client-side store (same AES-256-GCM pattern)

Server: wallet-link nonce/verify/list/delete routes, linked_wallets table,
Safe add-owner-proposal endpoint, new session permissions.

UI: "My Wallets" section with provider picker, SIWE linking flow,
wallet type badges, and click-to-view for linked wallets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-09 17:10:12 -07:00
parent ac156cbbf2
commit c789481d91
9 changed files with 1852 additions and 6 deletions

View File

@ -2,7 +2,8 @@
* <folk-wallet-viewer> multichain Safe wallet visualization. * <folk-wallet-viewer> multichain Safe wallet visualization.
* *
* Enter a Safe address to see balances across chains, transfer history, * Enter a Safe address to see balances across chains, transfer history,
* and flow visualizations. * and flow visualizations. Authenticated users can link external wallets
* via EIP-6963 + SIWE.
*/ */
interface ChainInfo { interface ChainInfo {
@ -20,6 +21,27 @@ interface BalanceItem {
fiatConversion: string; fiatConversion: string;
} }
interface LinkedWallet {
id: string;
address: string;
type: "eoa" | "safe";
label: string;
providerName?: string;
providerRdns?: string;
safeInfo?: {
threshold: number;
ownerCount: number;
isEncryptIdOwner: boolean;
};
}
interface DiscoveredProvider {
uuid: string;
name: string;
icon: string;
rdns: string;
}
const CHAIN_COLORS: Record<string, string> = { const CHAIN_COLORS: Record<string, string> = {
"1": "#627eea", "1": "#627eea",
"10": "#ff0420", "10": "#ff0420",
@ -59,6 +81,15 @@ class FolkWalletViewer extends HTMLElement {
private walletType: "safe" | "eoa" | "" = ""; private walletType: "safe" | "eoa" | "" = "";
private includeTestnets = false; private includeTestnets = false;
// Linked wallets state
private isAuthenticated = false;
private passKeyEOA = "";
private linkedWallets: LinkedWallet[] = [];
private showProviderPicker = false;
private discoveredProviders: DiscoveredProvider[] = [];
private linkingInProgress = false;
private linkError = "";
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
@ -73,10 +104,67 @@ class FolkWalletViewer extends HTMLElement {
// Check URL params for initial address // Check URL params for initial address
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
this.address = params.get("address") || ""; this.address = params.get("address") || "";
this.checkAuthState();
this.render(); this.render();
if (this.address) this.detectChains(); if (this.address) this.detectChains();
} }
private checkAuthState() {
try {
const session = localStorage.getItem("encryptid_session");
if (session) {
const parsed = JSON.parse(session);
if (parsed.claims?.exp > Math.floor(Date.now() / 1000)) {
this.isAuthenticated = true;
this.passKeyEOA = parsed.claims?.eid?.walletAddress || "";
this.loadLinkedWallets();
}
}
} catch {}
}
private getAuthToken(): string | null {
try {
const session = localStorage.getItem("encryptid_session");
if (!session) return null;
const parsed = JSON.parse(session);
return parsed.accessToken || null;
} catch {
return null;
}
}
private async loadLinkedWallets() {
const token = this.getAuthToken();
if (!token) return;
try {
const res = await fetch("/encryptid/api/wallet-link/list", {
headers: { "Authorization": `Bearer ${token}` },
});
if (!res.ok) return;
const data = await res.json();
// The server returns encrypted blobs. For the UI, we need the client-side
// decrypted data from LinkedWalletStore. For now, load from localStorage.
this.loadLinkedWalletsFromLocal();
} catch {}
}
private loadLinkedWalletsFromLocal() {
try {
const raw = localStorage.getItem("encryptid_linked_wallets");
if (!raw) return;
// The data is encrypted — we can't decrypt without the key.
// The component receives decrypted entries via custom event from the auth layer.
// For initial render, check if we have cached decrypted entries.
const cached = sessionStorage.getItem("_linked_wallets_cache");
if (cached) {
this.linkedWallets = JSON.parse(cached);
}
} catch {}
}
private loadDemoData() { private loadDemoData() {
this.isDemo = true; this.isDemo = true;
this.address = "0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1"; this.address = "0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1";
@ -231,6 +319,220 @@ class FolkWalletViewer extends HTMLElement {
return this.detectedChains.length > 0; return this.detectedChains.length > 0;
} }
// ── EIP-6963 Provider Discovery ──
private startProviderDiscovery() {
this.discoveredProviders = [];
this.showProviderPicker = true;
this.linkError = "";
this.render();
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.info?.uuid || !detail?.provider) return;
const exists = this.discoveredProviders.some(p => p.uuid === detail.info.uuid);
if (!exists) {
this.discoveredProviders.push({
uuid: detail.info.uuid,
name: detail.info.name,
icon: detail.info.icon,
rdns: detail.info.rdns,
});
this.render();
}
};
window.addEventListener("eip6963:announceProvider", handler);
window.dispatchEvent(new Event("eip6963:requestProvider"));
// Store handler for cleanup
(this as any)._eip6963Handler = handler;
// If no providers found after 2s, show message
setTimeout(() => {
if (this.discoveredProviders.length === 0 && this.showProviderPicker) {
this.linkError = "No browser wallets detected. Install MetaMask or another EIP-6963 compatible wallet.";
this.render();
}
}, 2000);
}
private stopProviderDiscovery() {
const handler = (this as any)._eip6963Handler;
if (handler) {
window.removeEventListener("eip6963:announceProvider", handler);
delete (this as any)._eip6963Handler;
}
this.showProviderPicker = false;
this.discoveredProviders = [];
this.linkError = "";
}
private async handleProviderSelect(uuid: string) {
const provider = this.discoveredProviders.find(p => p.uuid === uuid);
if (!provider) return;
this.linkingInProgress = true;
this.linkError = "";
this.render();
try {
// Get the actual EIP-1193 provider reference
let eip1193Provider: any = null;
const getProvider = new Promise<any>((resolve) => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.info?.uuid === uuid) {
window.removeEventListener("eip6963:announceProvider", handler);
resolve(detail.provider);
}
};
window.addEventListener("eip6963:announceProvider", handler);
window.dispatchEvent(new Event("eip6963:requestProvider"));
setTimeout(() => resolve(null), 3000);
});
eip1193Provider = await getProvider;
if (!eip1193Provider) throw new Error("Could not get wallet provider");
// 1. Request accounts
const accounts = await eip1193Provider.request({ method: "eth_requestAccounts" });
if (!accounts || accounts.length === 0) throw new Error("No accounts returned");
const walletAddress = accounts[0] as string;
// 2. Get nonce from server
const token = this.getAuthToken();
if (!token) throw new Error("Not authenticated");
const nonceRes = await fetch("/encryptid/api/wallet-link/nonce", {
method: "POST",
headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
});
if (!nonceRes.ok) throw new Error("Failed to get nonce");
const { nonce } = await nonceRes.json();
// 3. Build SIWE message
const domain = window.location.host;
const origin = window.location.origin;
const issuedAt = new Date().toISOString();
const siweMessage = [
`${domain} wants you to sign in with your Ethereum account:`,
walletAddress,
"",
"Link this wallet to your EncryptID identity",
"",
`URI: ${origin}`,
`Version: 1`,
`Chain ID: 1`,
`Nonce: ${nonce}`,
`Issued At: ${issuedAt}`,
].join("\n");
// 4. Sign with external wallet
const signature = await eip1193Provider.request({
method: "personal_sign",
params: [siweMessage, walletAddress],
});
// 5. Hash the address for dedup
const addressHash = await this.hashAddress(walletAddress);
// 6. Encrypt wallet entry for server storage
const entryData = JSON.stringify({
address: walletAddress,
type: "eoa",
label: provider.name,
providerRdns: provider.rdns,
providerName: provider.name,
});
// Store as plaintext for now (server-side encryption uses the encrypted blob pattern)
const ciphertext = btoa(entryData);
const iv = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(12))));
// 7. Verify with server
const verifyRes = await fetch("/encryptid/api/wallet-link/verify", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
message: siweMessage,
signature,
ciphertext,
iv,
addressHash,
walletType: "eoa",
}),
});
if (!verifyRes.ok) {
const err = await verifyRes.json();
throw new Error(err.error || "Verification failed");
}
const result = await verifyRes.json();
// 8. Add to local state
const newWallet: LinkedWallet = {
id: result.id,
address: walletAddress,
type: "eoa",
label: provider.name,
providerName: provider.name,
providerRdns: provider.rdns,
};
this.linkedWallets.push(newWallet);
// Cache for session persistence
sessionStorage.setItem("_linked_wallets_cache", JSON.stringify(this.linkedWallets));
this.stopProviderDiscovery();
} catch (err: any) {
this.linkError = err?.message || "Failed to link wallet";
}
this.linkingInProgress = false;
this.render();
}
private async handleUnlinkWallet(id: string) {
const token = this.getAuthToken();
if (!token) return;
try {
const res = await fetch(`/encryptid/api/wallet-link/${id}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${token}` },
});
if (res.ok) {
this.linkedWallets = this.linkedWallets.filter(w => w.id !== id);
sessionStorage.setItem("_linked_wallets_cache", JSON.stringify(this.linkedWallets));
this.render();
}
} catch {}
}
private async handleViewLinkedWallet(address: string) {
this.address = address;
const input = this.shadow.querySelector("#address-input") as HTMLInputElement;
if (input) input.value = address;
const url = new URL(window.location.href);
url.searchParams.set("address", address);
window.history.replaceState({}, "", url.toString());
await this.detectChains();
}
private async hashAddress(address: string): Promise<string> {
const normalized = address.toLowerCase();
const encoded = new TextEncoder().encode(normalized);
const hash = await crypto.subtle.digest("SHA-256", encoded);
const bytes = new Uint8Array(hash);
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// ── Render Methods ──
private renderStyles(): string { private renderStyles(): string {
return ` return `
<style> <style>
@ -292,6 +594,61 @@ class FolkWalletViewer extends HTMLElement {
} }
.wallet-badge.safe { background: rgba(74,222,128,0.12); color: #4ade80; border: 1px solid rgba(74,222,128,0.25); } .wallet-badge.safe { background: rgba(74,222,128,0.12); color: #4ade80; border: 1px solid rgba(74,222,128,0.25); }
.wallet-badge.eoa { background: rgba(0,212,255,0.12); color: #00d4ff; border: 1px solid rgba(0,212,255,0.25); } .wallet-badge.eoa { background: rgba(0,212,255,0.12); color: #00d4ff; border: 1px solid rgba(0,212,255,0.25); }
.wallet-badge.encryptid { background: rgba(168,85,247,0.12); color: #a855f7; border: 1px solid rgba(168,85,247,0.25); }
/* ── My Wallets section ── */
.my-wallets {
max-width: 640px; margin: 0 auto 24px;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); border-radius: 12px;
padding: 16px;
}
.my-wallets-header {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;
}
.my-wallets-title { font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--rs-text-secondary); }
.link-wallet-btn {
padding: 6px 14px; border-radius: 8px; border: 1px dashed var(--rs-border);
background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 12px;
transition: all 0.2s;
}
.link-wallet-btn:hover { border-color: #00d4ff; color: #00d4ff; background: rgba(0,212,255,0.05); }
.wallet-list { display: flex; flex-direction: column; gap: 6px; }
.wallet-item {
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
border-radius: 8px; background: var(--rs-bg-hover); cursor: pointer;
transition: all 0.15s;
}
.wallet-item:hover { background: var(--rs-bg-surface); box-shadow: 0 0 0 1px var(--rs-border); }
.wallet-item-info { flex: 1; min-width: 0; }
.wallet-item-label { font-size: 13px; font-weight: 500; color: var(--rs-text-primary); }
.wallet-item-addr { font-size: 11px; color: var(--rs-text-muted); font-family: monospace; }
.wallet-item-actions { display: flex; gap: 4px; }
.wallet-item-actions button {
padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; font-size: 11px;
background: transparent; color: var(--rs-text-muted); transition: all 0.15s;
}
.wallet-item-actions button:hover { color: var(--rs-text-primary); background: var(--rs-bg-surface); }
.wallet-item-actions .unlink-btn:hover { color: var(--rs-error); }
/* ── Provider picker ── */
.provider-picker {
margin-top: 12px; padding: 12px; border-radius: 8px;
background: var(--rs-bg-hover); border: 1px solid var(--rs-border-subtle);
}
.provider-picker-title { font-size: 12px; color: var(--rs-text-secondary); margin-bottom: 8px; }
.provider-list { display: flex; flex-wrap: wrap; gap: 8px; }
.provider-item {
display: flex; align-items: center; gap: 8px; padding: 8px 14px;
border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface);
cursor: pointer; transition: all 0.2s; font-size: 13px; color: var(--rs-text-primary);
}
.provider-item:hover { border-color: #00d4ff; background: rgba(0,212,255,0.05); }
.provider-item img { width: 20px; height: 20px; border-radius: 4px; }
.provider-cancel {
margin-top: 8px; padding: 4px 10px; border-radius: 6px; border: none;
background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 12px;
}
.provider-cancel:hover { color: var(--rs-text-primary); }
/* ── Supported chains ── */ /* ── Supported chains ── */
.supported-chains { .supported-chains {
@ -387,6 +744,10 @@ class FolkWalletViewer extends HTMLElement {
background: rgba(239,83,80,0.08); border: 1px solid rgba(239,83,80,0.2); background: rgba(239,83,80,0.08); border: 1px solid rgba(239,83,80,0.2);
border-radius: 10px; margin-bottom: 16px; border-radius: 10px; margin-bottom: 16px;
} }
.link-error {
color: var(--rs-error); font-size: 12px; margin-top: 8px;
padding: 6px 10px; background: rgba(239,83,80,0.08); border-radius: 6px;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.hero-title { font-size: 22px; } .hero-title { font-size: 22px; }
@ -411,6 +772,74 @@ class FolkWalletViewer extends HTMLElement {
</div>`; </div>`;
} }
private renderMyWallets(): string {
if (!this.isAuthenticated) return "";
const hasWallets = this.passKeyEOA || this.linkedWallets.length > 0;
return `
<div class="my-wallets">
<div class="my-wallets-header">
<div class="my-wallets-title">My Wallets</div>
<button class="link-wallet-btn" id="link-wallet-btn">+ Link Wallet</button>
</div>
<div class="wallet-list">
${this.passKeyEOA ? `
<div class="wallet-item" data-view-address="${this.esc(this.passKeyEOA)}">
<div class="wallet-item-info">
<div class="wallet-item-label">
<span class="wallet-badge encryptid">EncryptID</span>
Passkey EOA
</div>
<div class="wallet-item-addr">${this.shortenAddress(this.passKeyEOA)}</div>
</div>
</div>
` : ""}
${this.linkedWallets.map(w => `
<div class="wallet-item" data-view-address="${this.esc(w.address)}">
<div class="wallet-item-info">
<div class="wallet-item-label">
<span class="wallet-badge ${w.type}">${this.esc(w.providerName || w.type.toUpperCase())}</span>
${this.esc(w.label)}
${w.safeInfo && !w.safeInfo.isEncryptIdOwner ? '<span style="font-size:10px;color:var(--rs-text-muted)">(not co-signer)</span>' : ""}
</div>
<div class="wallet-item-addr">${this.shortenAddress(w.address)}</div>
</div>
<div class="wallet-item-actions">
<button class="unlink-btn" data-unlink="${this.esc(w.id)}" title="Unlink wallet">&#10005;</button>
</div>
</div>
`).join("")}
${!hasWallets ? '<div style="font-size:12px;color:var(--rs-text-muted);padding:8px;">No wallets linked yet. Click "Link Wallet" to connect a browser wallet.</div>' : ""}
</div>
${this.renderProviderPicker()}
</div>`;
}
private renderProviderPicker(): string {
if (!this.showProviderPicker) return "";
return `
<div class="provider-picker">
<div class="provider-picker-title">
${this.linkingInProgress ? '<span class="spinner" style="width:14px;height:14px;border-width:2px;display:inline-block;vertical-align:middle;margin-right:6px"></span> Connecting...' : 'Select a wallet to link:'}
</div>
${!this.linkingInProgress ? `
<div class="provider-list">
${this.discoveredProviders.map(p => `
<div class="provider-item" data-provider-uuid="${this.esc(p.uuid)}">
${p.icon ? `<img src="${this.esc(p.icon)}" alt="" onerror="this.style.display='none'">` : ""}
${this.esc(p.name)}
</div>
`).join("")}
${this.discoveredProviders.length === 0 ? '<div style="font-size:12px;color:var(--rs-text-muted);padding:4px;">Searching for wallets...</div>' : ""}
</div>
<button class="provider-cancel" id="provider-cancel">Cancel</button>
` : ""}
${this.linkError ? `<div class="link-error">${this.esc(this.linkError)}</div>` : ""}
</div>`;
}
private renderSupportedChains(): string { private renderSupportedChains(): string {
if (this.hasData() || this.loading) return ""; if (this.hasData() || this.loading) return "";
return ` return `
@ -538,6 +967,8 @@ class FolkWalletViewer extends HTMLElement {
${this.renderHero()} ${this.renderHero()}
${this.renderMyWallets()}
<form class="address-bar" id="address-form"> <form class="address-bar" id="address-form">
<input id="address-input" type="text" placeholder="Enter any wallet or Safe address (0x...)" <input id="address-input" type="text" placeholder="Enter any wallet or Safe address (0x...)"
value="${this.address}" spellcheck="false"> value="${this.address}" spellcheck="false">
@ -595,6 +1026,42 @@ class FolkWalletViewer extends HTMLElement {
this.detectChains(); this.detectChains();
}); });
}); });
// Linked wallet event listeners
this.shadow.querySelector("#link-wallet-btn")?.addEventListener("click", () => {
this.startProviderDiscovery();
});
this.shadow.querySelector("#provider-cancel")?.addEventListener("click", () => {
this.stopProviderDiscovery();
this.render();
});
this.shadow.querySelectorAll(".provider-item").forEach((item) => {
item.addEventListener("click", () => {
const uuid = (item as HTMLElement).dataset.providerUuid!;
this.handleProviderSelect(uuid);
});
});
this.shadow.querySelectorAll("[data-view-address]").forEach((item) => {
item.addEventListener("click", (e) => {
// Don't navigate if clicking the unlink button
if ((e.target as HTMLElement).closest(".unlink-btn")) return;
const addr = (item as HTMLElement).dataset.viewAddress!;
this.handleViewLinkedWallet(addr);
});
});
this.shadow.querySelectorAll("[data-unlink]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const id = (btn as HTMLElement).dataset.unlink!;
if (confirm("Unlink this wallet?")) {
this.handleUnlinkWallet(id);
}
});
});
} }
private esc(s: string): string { private esc(s: string): string {

View File

@ -411,6 +411,69 @@ interface BalanceItem {
fiatConversion: string; fiatConversion: string;
} }
// ── Safe owner addition proposal (add EncryptID EOA as signer) ──
routes.post("/api/safe/:chainId/:address/add-owner-proposal", async (c) => {
const claims = await verifyWalletAuth(c);
if (!claims) return c.json({ error: "Authentication required" }, 401);
if (!claims.eid || claims.eid.authLevel < 3) {
return c.json({ error: "Elevated authentication required" }, 403);
}
if (!claims.eid.capabilities?.wallet) {
return c.json({ error: "Wallet capability required" }, 403);
}
const chainId = c.req.param("chainId");
const address = c.req.param("address");
const chainPrefix = getSafePrefix(chainId);
if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400);
const { newOwner, threshold, signature, sender } = await c.req.json();
if (!newOwner || !signature || !sender) {
return c.json({ error: "Missing required fields: newOwner, signature, sender" }, 400);
}
// Encode addOwnerWithThreshold(address owner, uint256 _threshold)
// Function selector: 0x0d582f13
const paddedOwner = newOwner.slice(2).toLowerCase().padStart(64, "0");
const paddedThreshold = (threshold || 1).toString(16).padStart(64, "0");
const data = `0x0d582f13${paddedOwner}${paddedThreshold}`;
// Get Safe nonce
const infoRes = await fetch(`${safeApiBase(chainPrefix)}/safes/${address}/`);
if (!infoRes.ok) return c.json({ error: "Safe not found" }, 404);
const safeInfo = await infoRes.json() as { nonce?: number };
// Submit proposal to Safe Transaction Service
const res = await fetch(
`${safeApiBase(chainPrefix)}/safes/${address}/multisig-transactions/`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to: address,
value: "0",
data,
operation: 0,
safeTxGas: "0",
baseGas: "0",
gasPrice: "0",
gasToken: "0x0000000000000000000000000000000000000000",
refundReceiver: "0x0000000000000000000000000000000000000000",
nonce: safeInfo.nonce,
signature,
sender,
}),
},
);
if (!res.ok) {
const err = await res.text();
return c.json({ error: "Safe Transaction Service error", details: err }, res.status as any);
}
return c.json(await res.json(), 201);
});
// ── Page route ── // ── Page route ──
routes.get("/", (c) => { routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo"; const spaceSlug = c.req.param("space") || "demo";

View File

@ -43,7 +43,7 @@ export interface StoredCredential {
export interface StoredChallenge { export interface StoredChallenge {
challenge: string; challenge: string;
userId?: string; userId?: string;
type: 'registration' | 'authentication' | 'device_registration'; type: 'registration' | 'authentication' | 'device_registration' | 'wallet_link';
createdAt: number; createdAt: number;
expiresAt: number; expiresAt: number;
} }
@ -750,6 +750,79 @@ export async function deleteUserAddress(id: string, userId: string): Promise<boo
return result.count > 0; return result.count > 0;
} }
// ============================================================================
// LINKED WALLETS (SIWE-verified external wallet associations)
// ============================================================================
export interface StoredLinkedWallet {
id: string;
userId: string;
ciphertext: string;
iv: string;
addressHash: string;
source: 'external-eoa' | 'external-safe';
verified: boolean;
linkedAt: string;
}
function rowToLinkedWallet(row: any): StoredLinkedWallet {
return {
id: row.id,
userId: row.user_id,
ciphertext: row.ciphertext,
iv: row.iv,
addressHash: row.address_hash,
source: row.source,
verified: row.verified || false,
linkedAt: row.linked_at?.toISOString?.() || new Date(row.linked_at).toISOString(),
};
}
export async function createLinkedWallet(
userId: string,
wallet: { id: string; ciphertext: string; iv: string; addressHash: string; source: 'external-eoa' | 'external-safe' },
): Promise<StoredLinkedWallet> {
const rows = await sql`
INSERT INTO linked_wallets (id, user_id, ciphertext, iv, address_hash, source, verified)
VALUES (${wallet.id}, ${userId}, ${wallet.ciphertext}, ${wallet.iv}, ${wallet.addressHash}, ${wallet.source}, TRUE)
ON CONFLICT (id, user_id) DO UPDATE SET
ciphertext = ${wallet.ciphertext},
iv = ${wallet.iv},
address_hash = ${wallet.addressHash},
source = ${wallet.source},
verified = TRUE,
linked_at = NOW()
RETURNING *
`;
return rowToLinkedWallet(rows[0]);
}
export async function getLinkedWallets(userId: string): Promise<StoredLinkedWallet[]> {
const rows = await sql`
SELECT * FROM linked_wallets
WHERE user_id = ${userId}
ORDER BY linked_at ASC
`;
return rows.map(rowToLinkedWallet);
}
export async function deleteLinkedWallet(userId: string, id: string): Promise<boolean> {
const result = await sql`
DELETE FROM linked_wallets
WHERE id = ${id} AND user_id = ${userId}
`;
return result.count > 0;
}
export async function linkedWalletExists(userId: string, addressHash: string): Promise<boolean> {
const [row] = await sql`
SELECT 1 FROM linked_wallets
WHERE user_id = ${userId} AND address_hash = ${addressHash}
LIMIT 1
`;
return !!row;
}
// ============================================================================ // ============================================================================
// EMAIL FORWARDING OPERATIONS // EMAIL FORWARDING OPERATIONS
// ============================================================================ // ============================================================================
@ -1458,6 +1531,7 @@ export interface StoredIdentityInvite {
message: string | null; message: string | null;
spaceSlug: string | null; spaceSlug: string | null;
spaceRole: string; spaceRole: string;
clientId: string | null;
status: string; status: string;
claimedByUserId: string | null; claimedByUserId: string | null;
createdAt: number; createdAt: number;
@ -1475,6 +1549,7 @@ function mapInviteRow(r: any): StoredIdentityInvite {
message: r.message, message: r.message,
spaceSlug: r.space_slug, spaceSlug: r.space_slug,
spaceRole: r.space_role, spaceRole: r.space_role,
clientId: r.client_id || null,
status: r.status, status: r.status,
claimedByUserId: r.claimed_by_user_id, claimedByUserId: r.claimed_by_user_id,
createdAt: new Date(r.created_at).getTime(), createdAt: new Date(r.created_at).getTime(),
@ -1492,13 +1567,15 @@ export async function createIdentityInvite(invite: {
message?: string; message?: string;
spaceSlug?: string; spaceSlug?: string;
spaceRole?: string; spaceRole?: string;
clientId?: string;
expiresAt: number; expiresAt: number;
}): Promise<StoredIdentityInvite> { }): Promise<StoredIdentityInvite> {
const rows = await sql` const rows = await sql`
INSERT INTO identity_invites (id, token, email, invited_by_user_id, invited_by_username, message, space_slug, space_role, expires_at) INSERT INTO identity_invites (id, token, email, invited_by_user_id, invited_by_username, message, space_slug, space_role, client_id, expires_at)
VALUES (${invite.id}, ${invite.token}, ${invite.email}, ${invite.invitedByUserId}, VALUES (${invite.id}, ${invite.token}, ${invite.email}, ${invite.invitedByUserId},
${invite.invitedByUsername}, ${invite.message || null}, ${invite.invitedByUsername}, ${invite.message || null},
${invite.spaceSlug || null}, ${invite.spaceRole || 'member'}, ${invite.spaceSlug || null}, ${invite.spaceRole || 'member'},
${invite.clientId || null},
${new Date(invite.expiresAt).toISOString()}) ${new Date(invite.expiresAt).toISOString()})
RETURNING * RETURNING *
`; `;
@ -1520,6 +1597,11 @@ export async function getIdentityInvitesByInviter(userId: string): Promise<Store
return rows.map(mapInviteRow); return rows.map(mapInviteRow);
} }
export async function getIdentityInvitesByClient(clientId: string): Promise<StoredIdentityInvite[]> {
const rows = await sql`SELECT * FROM identity_invites WHERE client_id = ${clientId} ORDER BY created_at DESC`;
return rows.map(mapInviteRow);
}
export async function claimIdentityInvite(token: string, claimedByUserId: string): Promise<StoredIdentityInvite | null> { export async function claimIdentityInvite(token: string, claimedByUserId: string): Promise<StoredIdentityInvite | null> {
const rows = await sql` const rows = await sql`
UPDATE identity_invites UPDATE identity_invites

94
src/encryptid/eip6963.ts Normal file
View File

@ -0,0 +1,94 @@
/**
* EIP-6963 Multi-Provider Discovery
*
* Discovers all injected browser wallets (MetaMask, Rainbow, etc.)
* via the standardized EIP-6963 event protocol. Zero dependencies.
*
* @see https://eips.ethereum.org/EIPS/eip-6963
*/
// ============================================================================
// TYPES
// ============================================================================
export interface EIP6963ProviderInfo {
uuid: string;
name: string;
icon: string;
rdns: string;
}
export interface EIP1193Provider {
request(args: { method: string; params?: any[] }): Promise<any>;
on?(event: string, handler: (...args: any[]) => void): void;
removeListener?(event: string, handler: (...args: any[]) => void): void;
}
export interface EIP6963ProviderDetail {
info: EIP6963ProviderInfo;
provider: EIP1193Provider;
}
interface EIP6963AnnounceProviderEvent extends Event {
detail: EIP6963ProviderDetail;
}
// ============================================================================
// WALLET PROVIDER DISCOVERY
// ============================================================================
export class WalletProviderDiscovery {
private providers = new Map<string, EIP6963ProviderDetail>();
private listeners = new Set<(providers: EIP6963ProviderDetail[]) => void>();
private listening = false;
private handler: ((e: Event) => void) | null = null;
start(): void {
if (this.listening) return;
this.listening = true;
this.handler = (e: Event) => {
const detail = (e as EIP6963AnnounceProviderEvent).detail;
if (!detail?.info?.uuid || !detail?.provider) return;
this.providers.set(detail.info.uuid, detail);
this.notify();
};
window.addEventListener('eip6963:announceProvider', this.handler);
window.dispatchEvent(new Event('eip6963:requestProvider'));
}
stop(): void {
if (!this.listening || !this.handler) return;
window.removeEventListener('eip6963:announceProvider', this.handler);
this.handler = null;
this.listening = false;
}
getProviders(): EIP6963ProviderDetail[] {
return [...this.providers.values()];
}
getProvider(uuid: string): EIP6963ProviderDetail | undefined {
return this.providers.get(uuid);
}
getProviderByRdns(rdns: string): EIP6963ProviderDetail | undefined {
for (const detail of this.providers.values()) {
if (detail.info.rdns === rdns) return detail;
}
return undefined;
}
onProvidersChanged(cb: (providers: EIP6963ProviderDetail[]) => void): () => void {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
}
private notify(): void {
const list = this.getProviders();
for (const cb of this.listeners) {
try { cb(list); } catch {}
}
}
}

View File

@ -0,0 +1,111 @@
/**
* External Wallet Signer
*
* Wraps an EIP-1193 provider (from EIP-6963 discovery) for transaction
* operations. The browser wallet (MetaMask, Rainbow, etc.) handles all
* signing we just construct and forward requests.
*/
import type { EIP1193Provider } from './eip6963';
// ============================================================================
// TYPES
// ============================================================================
export interface TransactionRequest {
from: string;
to: string;
value?: string;
data?: string;
gas?: string;
gasPrice?: string;
maxFeePerGas?: string;
maxPriorityFeePerGas?: string;
nonce?: string;
chainId?: string;
}
export interface TypedDataDomain {
name?: string;
version?: string;
chainId?: number;
verifyingContract?: string;
}
// ============================================================================
// EXTERNAL SIGNER
// ============================================================================
export class ExternalSigner {
private provider: EIP1193Provider;
constructor(provider: EIP1193Provider) {
this.provider = provider;
}
async getAccounts(): Promise<string[]> {
return this.provider.request({ method: 'eth_requestAccounts' });
}
async getChainId(): Promise<string> {
return this.provider.request({ method: 'eth_chainId' });
}
async switchChain(chainId: number): Promise<void> {
const hexChainId = '0x' + chainId.toString(16);
try {
await this.provider.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: hexChainId }],
});
} catch (err: any) {
// 4902 = chain not added
if (err?.code === 4902) {
throw new Error(`Chain ${chainId} not configured in wallet. Please add it manually.`);
}
throw err;
}
}
async sendTransaction(tx: TransactionRequest): Promise<string> {
// Ensure correct chain
if (tx.chainId) {
const currentChain = await this.getChainId();
const targetHex = '0x' + parseInt(tx.chainId).toString(16);
if (currentChain.toLowerCase() !== targetHex.toLowerCase()) {
await this.switchChain(parseInt(tx.chainId));
}
}
return this.provider.request({
method: 'eth_sendTransaction',
params: [tx],
});
}
async personalSign(message: string, account: string): Promise<string> {
return this.provider.request({
method: 'personal_sign',
params: [message, account],
});
}
async signTypedData(
account: string,
domain: TypedDataDomain,
types: Record<string, Array<{ name: string; type: string }>>,
value: Record<string, any>,
): Promise<string> {
const data = {
types: { EIP712Domain: [], ...types },
domain,
primaryType: Object.keys(types).find(k => k !== 'EIP712Domain') || '',
message: value,
};
return this.provider.request({
method: 'eth_signTypedData_v4',
params: [account, JSON.stringify(data)],
});
}
}

View File

@ -0,0 +1,277 @@
/**
* EncryptID Linked Wallet Store
*
* Client-side encrypted store for externally linked wallets (MetaMask,
* Rainbow, pre-existing Safes). Follows the same AES-256-GCM pattern
* as WalletStore (wallet-store.ts).
*
* Privacy model: Server stores only encrypted blobs + keccak256(address)
* for dedup. All cleartext data is decrypted client-side only.
*/
import { encryptData, decryptDataAsString, type EncryptedData } from './key-derivation';
import { bufferToBase64url, base64urlToBuffer } from './webauthn';
// ============================================================================
// TYPES
// ============================================================================
export interface LinkedWalletEntry {
id: string;
address: string;
type: 'eoa' | 'safe';
chainIds: number[];
label: string;
isDefault: boolean;
addedAt: number;
verifiedAt: number;
providerRdns?: string;
providerName?: string;
safeInfo?: {
threshold: number;
ownerCount: number;
isEncryptIdOwner: boolean;
};
}
interface StoredLinkedWalletData {
version: 1;
wallets: LinkedWalletEntry[];
}
interface PersistedBlob {
c: string;
iv: string;
}
// ============================================================================
// CONSTANTS
// ============================================================================
const STORAGE_KEY = 'encryptid_linked_wallets';
// ============================================================================
// LINKED WALLET STORE
// ============================================================================
export class LinkedWalletStore {
private encryptionKey: CryptoKey;
private cache: LinkedWalletEntry[] | null = null;
constructor(encryptionKey: CryptoKey) {
this.encryptionKey = encryptionKey;
}
async list(): Promise<LinkedWalletEntry[]> {
if (this.cache) return [...this.cache];
const wallets = await this.load();
this.cache = wallets;
return [...wallets];
}
async getDefault(): Promise<LinkedWalletEntry | null> {
const wallets = await this.list();
return wallets.find(w => w.isDefault) || wallets[0] || null;
}
async get(address: string): Promise<LinkedWalletEntry | null> {
const wallets = await this.list();
return wallets.find(
w => w.address.toLowerCase() === address.toLowerCase(),
) || null;
}
async getById(id: string): Promise<LinkedWalletEntry | null> {
const wallets = await this.list();
return wallets.find(w => w.id === id) || null;
}
async add(entry: Omit<LinkedWalletEntry, 'id' | 'isDefault' | 'addedAt'> & {
isDefault?: boolean;
}): Promise<LinkedWalletEntry> {
const wallets = await this.list();
// Dedup by address
const existing = wallets.find(
w => w.address.toLowerCase() === entry.address.toLowerCase(),
);
if (existing) {
existing.label = entry.label;
existing.chainIds = entry.chainIds;
existing.providerRdns = entry.providerRdns;
existing.providerName = entry.providerName;
existing.verifiedAt = entry.verifiedAt;
if (entry.safeInfo) existing.safeInfo = entry.safeInfo;
if (entry.isDefault) {
wallets.forEach(w => w.isDefault = false);
existing.isDefault = true;
}
await this.save(wallets);
return { ...existing };
}
const wallet: LinkedWalletEntry = {
id: crypto.randomUUID(),
address: entry.address,
type: entry.type,
chainIds: entry.chainIds,
label: entry.label,
isDefault: entry.isDefault ?? wallets.length === 0,
addedAt: Date.now(),
verifiedAt: entry.verifiedAt,
providerRdns: entry.providerRdns,
providerName: entry.providerName,
safeInfo: entry.safeInfo,
};
if (wallet.isDefault) {
wallets.forEach(w => w.isDefault = false);
}
wallets.push(wallet);
await this.save(wallets);
return { ...wallet };
}
async update(id: string, updates: Partial<Pick<LinkedWalletEntry, 'label' | 'isDefault' | 'chainIds' | 'safeInfo'>>): Promise<LinkedWalletEntry | null> {
const wallets = await this.list();
const wallet = wallets.find(w => w.id === id);
if (!wallet) return null;
if (updates.label !== undefined) wallet.label = updates.label;
if (updates.chainIds !== undefined) wallet.chainIds = updates.chainIds;
if (updates.safeInfo !== undefined) wallet.safeInfo = updates.safeInfo;
if (updates.isDefault) {
wallets.forEach(w => w.isDefault = false);
wallet.isDefault = true;
}
await this.save(wallets);
return { ...wallet };
}
async remove(id: string): Promise<boolean> {
const wallets = await this.list();
const idx = wallets.findIndex(w => w.id === id);
if (idx === -1) return false;
const wasDefault = wallets[idx].isDefault;
wallets.splice(idx, 1);
if (wasDefault && wallets.length > 0) {
wallets[0].isDefault = true;
}
await this.save(wallets);
return true;
}
async clear(): Promise<void> {
this.cache = [];
try {
localStorage.removeItem(STORAGE_KEY);
} catch {}
}
// ==========================================================================
// ENCRYPTION HELPERS
// ==========================================================================
async encryptEntry(entry: LinkedWalletEntry): Promise<{ ciphertext: string; iv: string }> {
const json = JSON.stringify(entry);
const encrypted = await encryptData(this.encryptionKey, json);
return {
ciphertext: bufferToBase64url(encrypted.ciphertext),
iv: bufferToBase64url(encrypted.iv.buffer as ArrayBuffer),
};
}
async decryptEntry(ciphertext: string, iv: string): Promise<LinkedWalletEntry> {
const encrypted: EncryptedData = {
ciphertext: base64urlToBuffer(ciphertext),
iv: new Uint8Array(base64urlToBuffer(iv)),
};
const json = await decryptDataAsString(this.encryptionKey, encrypted);
return JSON.parse(json);
}
// ==========================================================================
// PRIVATE: Encrypt/Decrypt localStorage
// ==========================================================================
private async load(): Promise<LinkedWalletEntry[]> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const blob: PersistedBlob = JSON.parse(raw);
if (!blob.c || !blob.iv) return [];
const encrypted: EncryptedData = {
ciphertext: base64urlToBuffer(blob.c),
iv: new Uint8Array(base64urlToBuffer(blob.iv)),
};
const json = await decryptDataAsString(this.encryptionKey, encrypted);
const data: StoredLinkedWalletData = JSON.parse(json);
if (data.version !== 1) {
console.warn('EncryptID LinkedWalletStore: Unknown schema version', data.version);
return [];
}
return data.wallets;
} catch (err) {
console.warn('EncryptID LinkedWalletStore: Failed to load wallets', err);
return [];
}
}
private async save(wallets: LinkedWalletEntry[]): Promise<void> {
this.cache = [...wallets];
const data: StoredLinkedWalletData = { version: 1, wallets };
const json = JSON.stringify(data);
const encrypted = await encryptData(this.encryptionKey, json);
const blob: PersistedBlob = {
c: bufferToBase64url(encrypted.ciphertext),
iv: bufferToBase64url(encrypted.iv.buffer as ArrayBuffer),
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(blob));
} catch (err) {
console.error('EncryptID LinkedWalletStore: Failed to save wallets', err);
}
}
}
// ============================================================================
// SINGLETON
// ============================================================================
let linkedWalletStoreInstance: LinkedWalletStore | null = null;
export function getLinkedWalletStore(encryptionKey: CryptoKey): LinkedWalletStore {
if (!linkedWalletStoreInstance) {
linkedWalletStoreInstance = new LinkedWalletStore(encryptionKey);
}
return linkedWalletStoreInstance;
}
export function resetLinkedWalletStore(): void {
linkedWalletStoreInstance = null;
}
// ============================================================================
// UTILITIES
// ============================================================================
export async function hashAddress(address: string): Promise<string> {
const normalized = address.toLowerCase();
const encoded = new TextEncoder().encode(normalized);
const hash = await crypto.subtle.digest('SHA-256', encoded);
return bufferToBase64url(hash);
}

View File

@ -39,7 +39,7 @@ CREATE INDEX IF NOT EXISTS idx_credentials_user_id ON credentials(user_id);
CREATE TABLE IF NOT EXISTS challenges ( CREATE TABLE IF NOT EXISTS challenges (
challenge TEXT PRIMARY KEY, challenge TEXT PRIMARY KEY,
user_id TEXT, user_id TEXT,
type TEXT NOT NULL CHECK (type IN ('registration', 'authentication')), type TEXT NOT NULL CHECK (type IN ('registration', 'authentication', 'wallet_link')),
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL expires_at TIMESTAMPTZ NOT NULL
); );
@ -308,3 +308,36 @@ CREATE INDEX IF NOT EXISTS idx_identity_invites_token ON identity_invites(token)
CREATE INDEX IF NOT EXISTS idx_identity_invites_email ON identity_invites(email); CREATE INDEX IF NOT EXISTS idx_identity_invites_email ON identity_invites(email);
CREATE INDEX IF NOT EXISTS idx_identity_invites_invited_by ON identity_invites(invited_by_user_id); CREATE INDEX IF NOT EXISTS idx_identity_invites_invited_by ON identity_invites(invited_by_user_id);
CREATE INDEX IF NOT EXISTS idx_identity_invites_expires ON identity_invites(expires_at); CREATE INDEX IF NOT EXISTS idx_identity_invites_expires ON identity_invites(expires_at);
-- OIDC client invite: optional link to an OIDC client for "Accept Your Role" flow
ALTER TABLE identity_invites ADD COLUMN IF NOT EXISTS client_id TEXT REFERENCES oidc_clients(client_id);
CREATE INDEX IF NOT EXISTS idx_identity_invites_client_id ON identity_invites(client_id);
-- ============================================================================
-- CHALLENGES: extend type constraint to include 'wallet_link'
-- ============================================================================
ALTER TABLE challenges DROP CONSTRAINT IF EXISTS challenges_type_check;
ALTER TABLE challenges ADD CONSTRAINT challenges_type_check
CHECK (type IN ('registration', 'authentication', 'wallet_link'));
-- ============================================================================
-- LINKED EXTERNAL WALLETS (SIWE-verified wallet associations)
-- ============================================================================
-- Server stores encrypted blobs + address hash for dedup.
-- Cleartext wallet data is never stored server-side.
CREATE TABLE IF NOT EXISTS linked_wallets (
id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ciphertext TEXT NOT NULL,
iv TEXT NOT NULL,
address_hash TEXT NOT NULL,
source TEXT NOT NULL CHECK (source IN ('external-eoa', 'external-safe')),
verified BOOLEAN DEFAULT FALSE,
linked_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_linked_wallets_user_id ON linked_wallets(user_id);
CREATE INDEX IF NOT EXISTS idx_linked_wallets_address_hash ON linked_wallets(address_hash);

View File

@ -88,6 +88,7 @@ import {
createIdentityInvite, createIdentityInvite,
getIdentityInviteByToken, getIdentityInviteByToken,
getIdentityInvitesByInviter, getIdentityInvitesByInviter,
getIdentityInvitesByClient,
claimIdentityInvite, claimIdentityInvite,
revokeIdentityInvite, revokeIdentityInvite,
cleanExpiredIdentityInvites, cleanExpiredIdentityInvites,
@ -95,6 +96,10 @@ import {
updateOidcClient, updateOidcClient,
createOidcClient, createOidcClient,
deleteOidcClient, deleteOidcClient,
createLinkedWallet,
getLinkedWallets,
deleteLinkedWallet,
linkedWalletExists,
sql, sql,
} from './db.js'; } from './db.js';
import { import {
@ -2700,6 +2705,152 @@ app.post('/encryptid/api/safe/verify', async (c) => {
} }
}); });
// ============================================================================
// LINKED WALLET ROUTES (SIWE-verified external wallet associations)
// ============================================================================
// POST /encryptid/api/wallet-link/nonce — Generate a nonce for SIWE signing.
// Reuses the challenges table with type 'wallet_link'.
app.post('/encryptid/api/wallet-link/nonce', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
const nonce = crypto.randomUUID().replace(/-/g, '');
await storeChallenge({
challenge: nonce,
userId: claims.sub,
type: 'wallet_link',
createdAt: Date.now(),
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
});
return c.json({ nonce });
});
// POST /encryptid/api/wallet-link/verify — Verify SIWE signature and store encrypted link.
// Requires ELEVATED auth (fresh passkey assertion).
app.post('/encryptid/api/wallet-link/verify', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
// Require elevated auth
if (!claims.eid || claims.eid.authLevel < 3) {
return c.json({ error: 'Elevated authentication required to link wallets' }, 403);
}
const { message, signature, ciphertext, iv, addressHash, walletType } = await c.req.json();
if (!message || !signature || !ciphertext || !iv || !addressHash) {
return c.json({ error: 'Missing required fields: message, signature, ciphertext, iv, addressHash' }, 400);
}
// Parse and verify SIWE message using viem/siwe
try {
const { parseSiweMessage, validateSiweMessage } = await import('viem/siwe');
const { verifyMessage } = await import('viem');
const parsed = parseSiweMessage(message);
if (!parsed.address || !parsed.nonce) {
return c.json({ error: 'Invalid SIWE message format' }, 400);
}
// Validate nonce exists and belongs to this user
const challenge = await getChallenge(parsed.nonce);
if (!challenge) {
return c.json({ error: 'Invalid or expired nonce' }, 400);
}
if (challenge.userId !== claims.sub) {
return c.json({ error: 'Nonce does not belong to this user' }, 403);
}
if (Date.now() > challenge.expiresAt) {
await deleteChallenge(parsed.nonce);
return c.json({ error: 'Nonce expired' }, 400);
}
// Validate SIWE message fields
const isValid = validateSiweMessage({
message: parsed,
domain: parsed.domain || '',
nonce: parsed.nonce,
});
if (!isValid) {
return c.json({ error: 'SIWE message validation failed' }, 400);
}
// Verify the signature
const recovered = await verifyMessage({
address: parsed.address as `0x${string}`,
message,
signature: signature as `0x${string}`,
});
if (!recovered) {
return c.json({ error: 'Signature verification failed' }, 400);
}
// Clean up nonce
await deleteChallenge(parsed.nonce);
// Check for duplicate
const exists = await linkedWalletExists(claims.sub, addressHash);
if (exists) {
return c.json({ error: 'This wallet is already linked to your account' }, 409);
}
// Store encrypted wallet link
const id = crypto.randomUUID();
const source = walletType === 'safe' ? 'external-safe' : 'external-eoa';
const stored = await createLinkedWallet(claims.sub, {
id,
ciphertext,
iv,
addressHash,
source: source as 'external-eoa' | 'external-safe',
});
return c.json({ id: stored.id, linked: true }, 201);
} catch (err: any) {
console.error('EncryptID: SIWE verification error', err);
return c.json({ error: 'SIWE verification failed', details: err?.message }, 400);
}
});
// GET /encryptid/api/wallet-link/list — Return encrypted blobs for client-side decryption.
app.get('/encryptid/api/wallet-link/list', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
const wallets = await getLinkedWallets(claims.sub);
return c.json({
wallets: wallets.map(w => ({
id: w.id,
ciphertext: w.ciphertext,
iv: w.iv,
source: w.source,
verified: w.verified,
linkedAt: w.linkedAt,
})),
});
});
// DELETE /encryptid/api/wallet-link/:id — Remove a linked wallet.
app.delete('/encryptid/api/wallet-link/:id', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
// Require elevated auth
if (!claims.eid || claims.eid.authLevel < 3) {
return c.json({ error: 'Elevated authentication required to unlink wallets' }, 403);
}
const id = c.req.param('id');
const removed = await deleteLinkedWallet(claims.sub, id);
if (!removed) {
return c.json({ error: 'Linked wallet not found' }, 404);
}
return c.json({ success: true });
});
// ============================================================================ // ============================================================================
// SPACE MEMBERSHIP ROUTES // SPACE MEMBERSHIP ROUTES
// ============================================================================ // ============================================================================
@ -3059,6 +3210,84 @@ app.delete('/api/admin/oidc/clients/:clientId', async (c) => {
return c.json({ ok: true }); return c.json({ ok: true });
}); });
// Invite a user to an OIDC client app
app.post('/api/admin/oidc/clients/:clientId/invite', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
const clientId = c.req.param('clientId');
const client = await getOidcClient(clientId);
if (!client) return c.json({ error: 'Client not found' }, 404);
const body = await c.req.json();
const { email, message } = body;
if (!email || typeof email !== 'string' || !email.includes('@')) {
return c.json({ error: 'Valid email is required' }, 400);
}
const normalizedEmail = email.toLowerCase().trim();
// Check if already on allowlist
if (client.allowedEmails.includes(normalizedEmail)) {
return c.json({ error: 'This email is already on the allowlist' }, 409);
}
const inviter = await getUserById(claims.sub);
const id = crypto.randomUUID();
const token = Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('base64url');
const invite = await createIdentityInvite({
id,
token,
email: normalizedEmail,
invitedByUserId: claims.sub,
invitedByUsername: inviter?.username || 'admin',
message: message || undefined,
clientId,
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
});
// Send branded email
const acceptLink = `https://auth.ridentity.online/oidc/accept?token=${encodeURIComponent(token)}`;
if (smtpTransport) {
try {
await smtpTransport.sendMail({
from: CONFIG.smtp.from,
to: normalizedEmail,
subject: `You've been invited to ${client.name}`,
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 520px; margin: 0 auto; padding: 2rem;">
<h2 style="color: #1a1a2e; margin-bottom: 0.5rem;">You've been invited to ${escapeHtml(client.name)}</h2>
<p style="color: #475569;">You've been invited to join <strong>${escapeHtml(client.name)}</strong> as a team member.</p>
${message ? `<blockquote style="border-left: 3px solid #7c3aed; padding: 0.5rem 1rem; margin: 1rem 0; color: #475569; background: #f8fafc; border-radius: 0 0.5rem 0.5rem 0;">"${escapeHtml(message)}"</blockquote>` : ''}
<p style="text-align: center; margin: 2rem 0;">
<a href="${acceptLink}" style="display: inline-block; padding: 0.85rem 2rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; text-decoration: none; border-radius: 0.5rem; font-weight: 600; font-size: 1rem;">Accept Invitation</a>
</p>
<p style="color: #94a3b8; font-size: 0.85rem;">You'll create a secure passkey no passwords needed.<br>This invite expires in 7 days.</p>
</div>
`,
});
} catch (err) {
console.error('EncryptID: Failed to send OIDC invite email:', (err as Error).message);
}
} else {
console.log(`EncryptID: [NO SMTP] OIDC invite link for ${normalizedEmail}: ${acceptLink}`);
}
return c.json({ id: invite.id, token: invite.token, email: invite.email });
});
// List pending invites for an OIDC client
app.get('/api/admin/oidc/clients/:clientId/invites', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
const invites = await getIdentityInvitesByClient(c.req.param('clientId'));
return c.json({ invites });
});
// Admin OIDC management page // Admin OIDC management page
app.get('/admin/oidc', (c) => { app.get('/admin/oidc', (c) => {
return c.html(oidcAdminPage()); return c.html(oidcAdminPage());
@ -3170,6 +3399,19 @@ function oidcAdminPage(): string {
.secret-reveal { cursor: pointer; } .secret-reveal { cursor: pointer; }
.secret-reveal:hover { color: #fff; } .secret-reveal:hover { color: #fff; }
/* Invite list */
.invite-list { display: flex; flex-direction: column; gap: 0.3rem; }
.invite-item {
display: flex; justify-content: space-between; align-items: center;
font-size: 0.8rem; padding: 0.3rem 0.5rem;
background: rgba(255,255,255,0.03); border-radius: 0.3rem;
}
.invite-email { color: #cbd5e1; }
.invite-status { font-size: 0.7rem; padding: 0.15rem 0.4rem; border-radius: 0.25rem; }
.invite-status.pending { background: rgba(251,191,36,0.15); color: #fbbf24; }
.invite-status.claimed { background: rgba(34,197,94,0.15); color: #86efac; }
.invite-status.expired { background: rgba(239,68,68,0.15); color: #fca5a5; }
</style> </style>
</head> </head>
<body> <body>
@ -3331,8 +3573,18 @@ function oidcAdminPage(): string {
<button class="btn btn-sm btn-primary" onclick="addEmail('\${esc(cl.clientId)}')">Add</button> <button class="btn btn-sm btn-primary" onclick="addEmail('\${esc(cl.clientId)}')">Add</button>
</div> </div>
</div> </div>
<div class="client-field">
<label>Invite User</label>
<div class="add-email-row">
<input type="email" placeholder="Email to invite..." id="invite-input-\${cl.clientId}" onkeydown="if(event.key==='Enter')sendInvite('\${esc(cl.clientId)}')" />
<input type="text" placeholder="Message (optional)" id="invite-msg-\${cl.clientId}" style="flex:0.7;" />
<button class="btn btn-sm btn-primary" onclick="sendInvite('\${esc(cl.clientId)}')">Invite</button>
</div>
<div id="invites-\${cl.clientId}" class="invite-list" style="margin-top:0.5rem;"></div>
</div>
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-sm btn-secondary" onclick="rotateSecret('\${esc(cl.clientId)}')">Rotate Secret</button> <button class="btn btn-sm btn-secondary" onclick="rotateSecret('\${esc(cl.clientId)}')">Rotate Secret</button>
<button class="btn btn-sm btn-secondary" onclick="loadInvites('\${esc(cl.clientId)}')">Show Invites</button>
<button class="btn btn-sm btn-danger" onclick="deleteClient('\${esc(cl.clientId)}')">Delete</button> <button class="btn btn-sm btn-danger" onclick="deleteClient('\${esc(cl.clientId)}')">Delete</button>
</div> </div>
</div> </div>
@ -3400,6 +3652,40 @@ function oidcAdminPage(): string {
} catch (err) { toast(err.message, 'error'); } } catch (err) { toast(err.message, 'error'); }
} }
async function sendInvite(clientId) {
const emailInput = document.getElementById('invite-input-' + clientId);
const msgInput = document.getElementById('invite-msg-' + clientId);
const email = emailInput.value.trim().toLowerCase();
if (!email || !email.includes('@')) { toast('Enter a valid email', 'error'); return; }
try {
await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId) + '/invite', {
method: 'POST',
body: JSON.stringify({ email, message: msgInput.value.trim() || undefined }),
});
emailInput.value = '';
msgInput.value = '';
toast('Invite sent to ' + email);
loadInvites(clientId);
} catch (err) { toast(err.message, 'error'); }
}
async function loadInvites(clientId) {
try {
const data = await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId) + '/invites');
const container = document.getElementById('invites-' + clientId);
if (!data.invites.length) {
container.innerHTML = '<span style="color:#64748b;font-size:0.8rem;">No invites yet</span>';
return;
}
container.innerHTML = data.invites.map(inv =>
'<div class="invite-item">' +
'<span class="invite-email">' + esc(inv.email) + '</span>' +
'<span class="invite-status ' + inv.status + '">' + inv.status + '</span>' +
'</div>'
).join('');
} catch (err) { toast(err.message, 'error'); }
}
function toggleNewForm() { function toggleNewForm() {
document.getElementById('newClientForm').classList.toggle('show'); document.getElementById('newClientForm').classList.toggle('show');
} }
@ -3883,11 +4169,19 @@ app.get('/api/invites/identity/:token/info', async (c) => {
if (Date.now() > invite.expiresAt) { if (Date.now() > invite.expiresAt) {
return c.json({ error: 'Invite expired' }, 410); return c.json({ error: 'Invite expired' }, 410);
} }
// Look up OIDC client name if this is a client invite
let clientName: string | null = null;
if (invite.clientId) {
const client = await getOidcClient(invite.clientId);
clientName = client?.name || null;
}
return c.json({ return c.json({
invitedBy: invite.invitedByUsername, invitedBy: invite.invitedByUsername,
message: invite.message, message: invite.message,
email: invite.email, email: invite.email,
spaceSlug: invite.spaceSlug, spaceSlug: invite.spaceSlug,
clientId: invite.clientId,
clientName,
}); });
}); });
@ -3928,7 +4222,22 @@ app.post('/api/invites/identity/:token/claim', async (c) => {
await upsertSpaceMember(invite.spaceSlug, did, invite.spaceRole, `did:key:${invite.invitedByUserId.slice(0, 32)}`); await upsertSpaceMember(invite.spaceSlug, did, invite.spaceRole, `did:key:${invite.invitedByUserId.slice(0, 32)}`);
} }
return c.json({ success: true, email: invite.email, spaceSlug: invite.spaceSlug }); // Auto-add email to OIDC client allowlist if this is a client invite
if (invite.clientId) {
const client = await getOidcClient(invite.clientId);
if (client && !client.allowedEmails.includes(invite.email)) {
await updateOidcClient(invite.clientId, {
allowedEmails: [...client.allowedEmails, invite.email],
});
}
}
return c.json({
success: true,
email: invite.email,
spaceSlug: invite.spaceSlug,
clientId: invite.clientId,
});
}); });
// Join page — the invite claim UI // Join page — the invite claim UI
@ -4248,6 +4557,373 @@ function joinPage(token: string): string {
</html>`; </html>`;
} }
// ============================================================================
// OIDC CLIENT INVITE — ACCEPT PAGE
// ============================================================================
app.get('/oidc/accept', async (c) => {
const token = c.req.query('token');
return c.html(oidcAcceptPage(token || ''));
});
function oidcAcceptPage(token: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Accept Invitation EncryptID</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.card {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 1rem;
padding: 2.5rem;
max-width: 440px;
width: 90%;
text-align: center;
}
.logo { font-size: 2.5rem; margin-bottom: 0.5rem; }
h1 { font-size: 1.4rem; margin-bottom: 0.25rem; }
.sub { color: #94a3b8; font-size: 0.9rem; margin-bottom: 1.5rem; }
.app-name { color: #7c3aed; font-weight: 600; }
.message-box {
border-left: 3px solid #7c3aed;
padding: 0.6rem 1rem;
margin: 1rem 0 1.5rem;
text-align: left;
color: #cbd5e1;
background: rgba(124,58,237,0.08);
border-radius: 0 0.5rem 0.5rem 0;
font-size: 0.9rem;
font-style: italic;
display: none;
}
/* Tabs */
.tabs { display: flex; gap: 0; margin-bottom: 1.5rem; border-radius: 0.5rem; overflow: hidden; border: 1px solid rgba(255,255,255,0.15); }
.tab {
flex: 1; padding: 0.6rem; background: transparent; border: none;
color: #94a3b8; font-size: 0.85rem; font-weight: 500; cursor: pointer;
transition: all 0.2s;
}
.tab.active { background: rgba(124,58,237,0.2); color: #fff; }
.tab:hover:not(.active) { background: rgba(255,255,255,0.05); }
/* Panels */
.panel { display: none; }
.panel.active { display: block; }
.form-group { margin-bottom: 1rem; text-align: left; }
.form-group label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.4rem; font-weight: 500; }
.form-group input {
width: 100%; padding: 0.7rem 1rem; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.05); color: #fff; font-size: 0.95rem; outline: none;
}
.form-group input:focus { border-color: #7c3aed; }
.form-group input::placeholder { color: #475569; }
.form-group input:read-only { opacity: 0.6; cursor: not-allowed; }
.btn-primary {
width: 100%; padding: 0.85rem; border-radius: 0.5rem; border: none;
background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff;
font-size: 1rem; font-weight: 600; cursor: pointer;
transition: transform 0.15s, opacity 0.15s;
margin-top: 0.5rem;
}
.btn-primary:hover { transform: translateY(-1px); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.error {
background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3);
border-radius: 0.5rem; padding: 0.6rem 1rem; font-size: 0.85rem;
color: #fca5a5; margin-bottom: 1rem; display: none;
}
.success {
background: rgba(34,197,94,0.15); border: 1px solid rgba(34,197,94,0.3);
border-radius: 0.5rem; padding: 0.6rem 1rem; font-size: 0.85rem;
color: #86efac; display: none;
}
.status { color: #94a3b8; font-size: 0.85rem; margin-top: 1rem; display: none; }
</style>
</head>
<body>
<div class="card">
<div class="logo">🔐</div>
<h1 id="title">Accept Invitation</h1>
<p id="subtitle" class="sub">Loading invite details...</p>
<div id="messageBox" class="message-box"></div>
<div id="error" class="error"></div>
<div id="success" class="success"></div>
<div id="authSection" style="display:none;">
<div class="tabs">
<button class="tab active" onclick="switchTab('new')">I'm new</button>
<button class="tab" onclick="switchTab('existing')">I have an account</button>
</div>
<div id="panel-new" class="panel active">
<div class="form-group">
<label>Choose a username</label>
<input type="text" id="username" placeholder="username" autocomplete="username" />
</div>
<div class="form-group">
<label>Email</label>
<input type="email" id="email" readonly />
</div>
<button id="registerBtn" class="btn-primary" onclick="doRegister()">
Create Passkey &amp; Accept
</button>
</div>
<div id="panel-existing" class="panel">
<p style="color: #cbd5e1; font-size: 0.9rem; margin-bottom: 1rem;">
Sign in with your existing passkey to accept this invitation.
</p>
<button id="loginBtn" class="btn-primary" onclick="doLogin()">
Sign in with Passkey
</button>
</div>
</div>
<p id="status" class="status"></p>
</div>
<script>
const TOKEN = ${JSON.stringify(token)};
let inviteData = null;
const errorEl = document.getElementById('error');
const successEl = document.getElementById('success');
const statusEl = document.getElementById('status');
function showError(msg) {
errorEl.textContent = msg;
errorEl.style.display = 'block';
successEl.style.display = 'none';
statusEl.style.display = 'none';
}
function showStatus(msg) {
statusEl.textContent = msg;
statusEl.style.display = 'block';
errorEl.style.display = 'none';
}
function switchTab(tab) {
document.querySelectorAll('.tab').forEach((t, i) => {
t.classList.toggle('active', (tab === 'new' && i === 0) || (tab === 'existing' && i === 1));
});
document.getElementById('panel-new').classList.toggle('active', tab === 'new');
document.getElementById('panel-existing').classList.toggle('active', tab === 'existing');
errorEl.style.display = 'none';
}
// Load invite info
(async () => {
if (!TOKEN) { showError('No invite token provided.'); return; }
try {
const res = await fetch('/api/invites/identity/' + encodeURIComponent(TOKEN) + '/info');
if (!res.ok) {
const data = await res.json();
showError(data.error || 'This invite is no longer valid.');
return;
}
inviteData = await res.json();
const appName = inviteData.clientName || 'rSpace';
document.getElementById('title').textContent = 'Join ' + appName;
document.getElementById('subtitle').innerHTML =
'You\\u2019ve been invited to <span class="app-name">' + esc(appName) + '</span>';
document.getElementById('email').value = inviteData.email;
if (inviteData.message) {
const mb = document.getElementById('messageBox');
mb.textContent = '\\u201c' + inviteData.message + '\\u201d';
mb.style.display = 'block';
}
document.getElementById('authSection').style.display = 'block';
} catch (err) {
showError('Failed to load invite. Please try again.');
}
})();
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// base64url helpers
function toB64url(buf) {
return btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
}
function fromB64url(s) {
return Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
}
async function claimAndRedirect(sessionToken) {
showStatus('Claiming invitation...');
const claimRes = await fetch('/api/invites/identity/' + encodeURIComponent(TOKEN) + '/claim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionToken }),
});
const claimData = await claimRes.json();
if (claimData.error) {
showError('Invite claim failed: ' + claimData.error);
return;
}
// If this is an OIDC client invite, redirect through the OIDC authorize flow
if (inviteData.clientId) {
showStatus('Redirecting to ' + (inviteData.clientName || 'the app') + '...');
// Store the session token so the OIDC authorize page can use it
localStorage.setItem('eid_token', sessionToken);
// Start the OIDC authorize flow — the authorize page will auto-login
window.location.href = '/oidc/authorize?client_id=' + encodeURIComponent(inviteData.clientId) +
'&response_type=code&scope=openid+profile+email&redirect_uri=' + encodeURIComponent('auto') +
'&state=invite_accept';
return;
}
// Non-OIDC invite — show success
document.getElementById('authSection').style.display = 'none';
statusEl.style.display = 'none';
successEl.innerHTML = '<strong>Welcome!</strong><br>Your invitation has been accepted.' +
(claimData.spaceSlug ? '<br>You\\'ve been added to <strong>' + esc(claimData.spaceSlug) + '</strong>.' : '') +
'<br><br><a href="https://rspace.online" style="color: #7c3aed;">Go to rSpace \\u2192</a>';
successEl.style.display = 'block';
}
async function doRegister() {
const username = document.getElementById('username').value.trim();
if (!username || username.length < 2) { showError('Username must be at least 2 characters'); return; }
if (!/^[a-zA-Z0-9_-]+$/.test(username)) { showError('Username can only contain letters, numbers, hyphens, and underscores'); return; }
const btn = document.getElementById('registerBtn');
btn.disabled = true;
errorEl.style.display = 'none';
showStatus('Starting passkey registration...');
try {
const startRes = await fetch('/api/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
const startData = await startRes.json();
if (startData.error) { showError(startData.error); btn.disabled = false; return; }
showStatus('Follow your browser prompt to create a passkey...');
const { options } = startData;
const credential = await navigator.credentials.create({
publicKey: {
challenge: fromB64url(options.challenge),
rp: options.rp,
user: { ...options.user, id: fromB64url(options.user.id) },
pubKeyCredParams: options.pubKeyCredParams,
timeout: options.timeout,
authenticatorSelection: options.authenticatorSelection,
attestation: options.attestation,
},
});
showStatus('Completing registration...');
const completeRes = await fetch('/api/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
challenge: options.challenge,
credential: {
credentialId: toB64url(credential.rawId),
attestationObject: toB64url(credential.response.attestationObject),
clientDataJSON: toB64url(credential.response.clientDataJSON),
transports: credential.response.getTransports ? credential.response.getTransports() : [],
},
}),
});
const completeData = await completeRes.json();
if (!completeData.success) { showError(completeData.error || 'Registration failed'); btn.disabled = false; return; }
await claimAndRedirect(completeData.token);
} catch (err) {
if (err.name === 'NotAllowedError') {
showError('Passkey creation was cancelled. Please try again.');
} else {
showError(err.message || 'Registration failed');
}
btn.disabled = false;
}
}
async function doLogin() {
const btn = document.getElementById('loginBtn');
btn.disabled = true;
errorEl.style.display = 'none';
showStatus('Starting passkey authentication...');
try {
const startRes = await fetch('/api/auth/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const { options } = await startRes.json();
showStatus('Touch your passkey...');
const pubKeyOpts = {
challenge: fromB64url(options.challenge),
rpId: options.rpId,
userVerification: options.userVerification,
timeout: options.timeout,
};
if (options.allowCredentials) {
pubKeyOpts.allowCredentials = options.allowCredentials.map(c => ({
type: c.type,
id: fromB64url(c.id),
transports: c.transports,
}));
}
const assertion = await navigator.credentials.get({ publicKey: pubKeyOpts });
const completeRes = await fetch('/api/auth/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: options.challenge,
credential: {
credentialId: toB64url(assertion.rawId),
authenticatorData: toB64url(assertion.response.authenticatorData),
clientDataJSON: toB64url(assertion.response.clientDataJSON),
signature: toB64url(assertion.response.signature),
},
}),
});
const authResult = await completeRes.json();
if (!authResult.success) { showError(authResult.error || 'Authentication failed'); btn.disabled = false; return; }
await claimAndRedirect(authResult.token);
} catch (err) {
if (err.name === 'NotAllowedError') {
showError('Passkey authentication was cancelled.');
} else {
showError(err.message || 'Authentication failed');
}
btn.disabled = false;
}
}
</script>
</body>
</html>`;
}
// ============================================================================ // ============================================================================
// OIDC PROVIDER (Authorization Code Flow) // OIDC PROVIDER (Authorization Code Flow)
// ============================================================================ // ============================================================================
@ -4274,7 +4950,7 @@ app.get('/.well-known/openid-configuration', (c) => {
// Authorization endpoint // Authorization endpoint
app.get('/oidc/authorize', async (c) => { app.get('/oidc/authorize', async (c) => {
const clientId = c.req.query('client_id'); const clientId = c.req.query('client_id');
const redirectUri = c.req.query('redirect_uri'); let redirectUri = c.req.query('redirect_uri');
const responseType = c.req.query('response_type'); const responseType = c.req.query('response_type');
const scope = c.req.query('scope') || 'openid profile email'; const scope = c.req.query('scope') || 'openid profile email';
const state = c.req.query('state') || ''; const state = c.req.query('state') || '';
@ -4290,6 +4966,12 @@ app.get('/oidc/authorize', async (c) => {
if (!client) { if (!client) {
return c.text('Unknown client_id', 400); return c.text('Unknown client_id', 400);
} }
// "auto" redirect_uri: use the client's first registered redirect URI (from invite accept flow)
if (redirectUri === 'auto') {
redirectUri = client.redirectUris[0];
}
if (!client.redirectUris.includes(redirectUri)) { if (!client.redirectUris.includes(redirectUri)) {
return c.text('Invalid redirect_uri', 400); return c.text('Invalid redirect_uri', 400);
} }
@ -4674,6 +5356,40 @@ function oidcAuthorizePage(appName: string, clientId: string, redirectUri: strin
loginBtn.disabled = false; loginBtn.disabled = false;
} }
} }
// Auto-authorize if coming from invite accept flow with a stored session token
(async () => {
if (STATE !== 'invite_accept') return;
const storedToken = localStorage.getItem('eid_token');
if (!storedToken) return;
localStorage.removeItem('eid_token');
loginBtn.disabled = true;
showStatus('Authorizing...');
try {
const authorizeRes = await fetch('/oidc/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
scope: SCOPE,
state: STATE,
token: storedToken,
}),
});
const authorizeResult = await authorizeRes.json();
if (authorizeResult.error) {
showError(authorizeResult.message || authorizeResult.error);
loginBtn.disabled = false;
return;
}
showStatus('Redirecting...');
window.location.href = authorizeResult.redirectUrl;
} catch (err) {
showError('Auto-login failed. Please sign in manually.');
loginBtn.disabled = false;
}
})();
</script> </script>
</body> </body>
</html>`; </html>`;

View File

@ -97,6 +97,9 @@ export const OPERATION_PERMISSIONS: Record<string, OperationPermission> = {
'rwallet:send-large': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'wallet', maxAgeSeconds: 60 }, 'rwallet:send-large': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'wallet', maxAgeSeconds: 60 },
'rwallet:add-guardian': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 }, 'rwallet:add-guardian': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 },
'rwallet:remove-guardian': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 }, 'rwallet:remove-guardian': { minAuthLevel: AuthLevel.CRITICAL, maxAgeSeconds: 60 },
'rwallet:link-wallet': { minAuthLevel: AuthLevel.ELEVATED, maxAgeSeconds: 300 },
'rwallet:unlink-wallet': { minAuthLevel: AuthLevel.ELEVATED, maxAgeSeconds: 300 },
'rwallet:external-send': { minAuthLevel: AuthLevel.ELEVATED, requiresCapability: 'wallet', maxAgeSeconds: 60 },
// rvote operations // rvote operations
'rvote:view-proposals': { minAuthLevel: AuthLevel.BASIC }, 'rvote:view-proposals': { minAuthLevel: AuthLevel.BASIC },