fix(auth): exchange WebAuthn credential for server-signed JWT + rNetwork CRM cleanup

- Session manager now calls EncryptID /api/auth/start + /api/auth/complete
  to get a properly signed JWT instead of creating unsigned local tokens.
  This fixes 401 errors on /api/payments, /api/notifications, and other
  authenticated endpoints that verify tokens via EncryptID server.
- Token refresh calls /api/session/refresh instead of extending unsigned tokens
- Server generateSessionToken now includes authTime, jti, recoveryConfigured
- rNetwork: /crm route renders folk-crm-view instead of iframe
- rNetwork: ?view=app redirects 301 to /crm (backward compat)
- rNetwork: graph viewer always uses API (removed hardcoded demo data)
- docker-compose: pass through TWENTY_API_TOKEN from Infisical
- rcart: add catalog product images

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 17:13:19 -07:00
parent c2c3d1fb06
commit 4212a651e1
6 changed files with 149 additions and 146 deletions

View File

@ -43,6 +43,7 @@ services:
- SMTP_USER=${SMTP_USER:-noreply@rmail.online} - SMTP_USER=${SMTP_USER:-noreply@rmail.online}
- SMTP_PASS=${SMTP_PASS} - SMTP_PASS=${SMTP_PASS}
- TWENTY_API_URL=http://twenty-ch-server:3000 - TWENTY_API_URL=http://twenty-ch-server:3000
- TWENTY_API_TOKEN=${TWENTY_API_TOKEN}
- OLLAMA_URL=http://ollama:11434 - OLLAMA_URL=http://ollama:11434
- INFISICAL_AI_CLIENT_ID=${INFISICAL_AI_CLIENT_ID} - INFISICAL_AI_CLIENT_ID=${INFISICAL_AI_CLIENT_ID}
- INFISICAL_AI_CLIENT_SECRET=${INFISICAL_AI_CLIENT_SECRET} - INFISICAL_AI_CLIENT_SECRET=${INFISICAL_AI_CLIENT_SECRET}

View File

@ -221,14 +221,14 @@ class FolkCartShop extends HTMLElement {
// Existing catalog demo data // Existing catalog demo data
this.catalog = [ this.catalog = [
{ id: "demo-cat-1", title: "The Commons", description: "A pocket book exploring shared resources and collective stewardship.", price: 12, currency: "USD", tags: ["books"], product_type: "pocket book", status: "active", created_at: new Date(now - 30 * 86400000).toISOString() }, { id: "demo-cat-1", title: "The Commons", description: "A pocket book exploring shared resources and collective stewardship.", price: 12, currency: "USD", tags: ["books"], product_type: "pocket book", status: "active", image_url: "/images/catalog/catalog-the-commons.jpg", created_at: new Date(now - 30 * 86400000).toISOString() },
{ id: "demo-cat-2", title: "Mycelium Networks", description: "Illustrated poster mapping underground fungal communication pathways.", price: 18, currency: "USD", tags: ["prints"], product_type: "poster", status: "active", created_at: new Date(now - 25 * 86400000).toISOString() }, { id: "demo-cat-2", title: "Mycelium Networks", description: "Illustrated poster mapping underground fungal communication pathways.", price: 18, currency: "USD", tags: ["prints"], product_type: "poster", status: "active", image_url: "/images/catalog/catalog-mycelium-networks.jpg", created_at: new Date(now - 25 * 86400000).toISOString() },
{ id: "demo-cat-3", title: "#DefectFi", description: "Organic cotton tee shirt with the #DefectFi campaign logo.", price: 25, currency: "USD", tags: ["apparel"], product_type: "tee shirt", status: "active", created_at: new Date(now - 20 * 86400000).toISOString() }, { id: "demo-cat-3", title: "#DefectFi Tee", description: "Organic cotton tee with the #DefectFi campaign logo. Circuit patterns dissolving into organic roots.", price: 25, currency: "USD", tags: ["apparel"], product_type: "tee shirt", status: "active", image_url: "/images/catalog/catalog-defectfi-tee.jpg", created_at: new Date(now - 20 * 86400000).toISOString() },
{ id: "demo-cat-4", title: "Cosmolocal Sticker Sheet", description: "Die-cut sticker sheet with cosmolocal design motifs.", price: 5, currency: "USD", tags: ["stickers"], product_type: "sticker sheet", status: "active", created_at: new Date(now - 15 * 86400000).toISOString() }, { id: "demo-cat-4", title: "Cosmolocal Sticker Sheet", description: "Die-cut sticker set with cosmolocal motifs — nodes, mycelium, community gardens, mesh networks.", price: 5, currency: "USD", tags: ["stickers"], product_type: "sticker sheet", status: "active", image_url: "/images/catalog/catalog-cosmolocal-stickers.jpg", created_at: new Date(now - 15 * 86400000).toISOString() },
{ id: "demo-cat-5", title: "Doughnut Economics", description: "A zine breaking down Kate Raworth's doughnut economics framework.", price: 8, currency: "USD", tags: ["books"], product_type: "zine", status: "active", created_at: new Date(now - 10 * 86400000).toISOString() }, { id: "demo-cat-5", title: "Doughnut Economics Zine", description: "Punk zine breaking down Kate Raworth's doughnut economics framework. Cut-and-paste collage aesthetic.", price: 8, currency: "USD", tags: ["books", "zines"], product_type: "zine", status: "active", image_url: "/images/catalog/catalog-doughnut-economics.jpg", created_at: new Date(now - 10 * 86400000).toISOString() },
{ id: "demo-cat-6", title: "rSpace Logo", description: "Embroidered patch featuring the rSpace logo on twill backing.", price: 6, currency: "USD", tags: ["accessories"], product_type: "embroidered patch", status: "active", created_at: new Date(now - 5 * 86400000).toISOString() }, { id: "demo-cat-6", title: "rSpace Logo Patch", description: "Embroidered patch featuring the rSpace logo in teal and white on navy twill backing.", price: 6, currency: "USD", tags: ["accessories"], product_type: "embroidered patch", status: "active", image_url: "/images/catalog/catalog-rspace-patch.jpg", created_at: new Date(now - 5 * 86400000).toISOString() },
{ id: "demo-cat-7", title: "Cosmolocal Network Tee", description: "Bella+Canvas 3001 tee with the Cosmolocal Network radial design.", price: 25, currency: "USD", tags: ["apparel", "cosmolocal"], product_type: "tee", required_capabilities: ["dtg-print"], status: "active", created_at: new Date(now - 3 * 86400000).toISOString() }, { id: "demo-cat-7", title: "Cosmolocal Network Tee", description: "Bella+Canvas 3001 tee with the Cosmolocal Network radial design in teal and coral.", price: 25, currency: "USD", tags: ["apparel", "cosmolocal"], product_type: "tee", required_capabilities: ["dtg-print"], status: "active", image_url: "/images/catalog/catalog-cosmolocal-tee.jpg", created_at: new Date(now - 3 * 86400000).toISOString() },
{ id: "demo-cat-8", title: "Cosmolocal Sticker Sheet", description: "Kiss-cut vinyl sticker sheet with cosmolocal network motifs.", price: 5, currency: "USD", tags: ["stickers", "cosmolocal"], product_type: "sticker-sheet", required_capabilities: ["vinyl-cut"], status: "active", created_at: new Date(now - 1 * 86400000).toISOString() }, { id: "demo-cat-8", title: "Cosmolocal Vinyl Stickers", description: "Kiss-cut vinyl sticker sheet with network constellation patterns and holographic accents.", price: 5, currency: "USD", tags: ["stickers", "cosmolocal"], product_type: "sticker-sheet", required_capabilities: ["vinyl-cut"], status: "active", image_url: "/images/catalog/catalog-cosmolocal-vinyl-stickers.jpg", created_at: new Date(now - 1 * 86400000).toISOString() },
]; ];
this.orders = [ this.orders = [
@ -657,19 +657,24 @@ class FolkCartShop extends HTMLElement {
return `<div class="empty">No items in the catalog yet. Ingest artifacts from rPubs or Swag Designer to list them here.</div>`; return `<div class="empty">No items in the catalog yet. Ingest artifacts from rPubs or Swag Designer to list them here.</div>`;
} }
return `<div class="grid"> return `<div class="grid catalog-grid">
${this.catalog.map((entry) => ` ${this.catalog.map((entry) => `
<div class="card" data-collab-id="product:${entry.id || entry.title}"> <div class="card catalog-card" data-collab-id="product:${entry.id || entry.title}">
<h3 class="card-title">${this.esc(entry.title || "Untitled")}</h3> ${entry.image_url ? `<div class="catalog-img"><img src="${this.esc(entry.image_url)}" alt="${this.esc(entry.title || '')}" loading="lazy" /></div>` : ""}
<div class="card-meta"> <div class="catalog-body">
${entry.product_type ? `<span class="tag tag-type">${this.esc(entry.product_type)}</span>` : ""} <h3 class="card-title">${this.esc(entry.title || "Untitled")}</h3>
${(entry.required_capabilities || []).map((cap: string) => `<span class="tag tag-cap">${this.esc(cap)}</span>`).join("")} <div class="card-meta">
${(entry.tags || []).map((t: string) => `<span class="tag tag-cap">${this.esc(t)}</span>`).join("")} ${entry.product_type ? `<span class="tag tag-type">${this.esc(entry.product_type)}</span>` : ""}
${(entry.required_capabilities || []).map((cap: string) => `<span class="tag tag-cap">${this.esc(cap)}</span>`).join("")}
${(entry.tags || []).map((t: string) => `<span class="tag tag-cap">${this.esc(t)}</span>`).join("")}
</div>
${entry.description ? `<div class="card-desc">${this.esc(entry.description)}</div>` : ""}
${entry.dimensions ? `<div class="dims">${entry.dimensions.width_mm}x${entry.dimensions.height_mm}mm</div>` : ""}
<div class="catalog-footer">
${entry.price != null ? `<div class="price">$${parseFloat(entry.price).toFixed(2)} ${entry.currency || ""}</div>` : ""}
<span class="status status-${entry.status}">${entry.status}</span>
</div>
</div> </div>
${entry.description ? `<div class="card-meta">${this.esc(entry.description)}</div>` : ""}
${entry.dimensions ? `<div class="dims">${entry.dimensions.width_mm}x${entry.dimensions.height_mm}mm</div>` : ""}
${entry.price != null ? `<div class="price">$${parseFloat(entry.price).toFixed(2)} ${entry.currency || ""}</div>` : ""}
<div style="margin-top:0.5rem"><span class="status status-${entry.status}">${entry.status}</span></div>
</div> </div>
`).join("")} `).join("")}
</div>`; </div>`;
@ -821,7 +826,18 @@ class FolkCartShop extends HTMLElement {
.status-cancelled, .status-closed { background: rgba(239,68,68,0.15); color: #f87171; } .status-cancelled, .status-closed { background: rgba(239,68,68,0.15); color: #f87171; }
.status-shipped { background: rgba(56,189,248,0.15); color: #38bdf8; } .status-shipped { background: rgba(56,189,248,0.15); color: #38bdf8; }
.price { color: var(--rs-text-primary); font-weight: 600; font-size: 1rem; margin-top: 0.5rem; } .price { color: var(--rs-text-primary); font-weight: 600; font-size: 1rem; }
/* Catalog product cards */
.catalog-grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
.catalog-card { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
.catalog-img { width: 100%; aspect-ratio: 1; overflow: hidden; background: var(--rs-bg-surface-raised); }
.catalog-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.3s; }
.catalog-card:hover .catalog-img img { transform: scale(1.04); }
.catalog-body { padding: 1rem; display: flex; flex-direction: column; flex: 1; }
.catalog-body .card-title { margin-bottom: 0.25rem; }
.card-desc { color: var(--rs-text-secondary); font-size: 0.8125rem; margin-bottom: 0.75rem; line-height: 1.4; flex: 1; }
.catalog-footer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; }
.text-green { color: #4ade80; } .text-green { color: #4ade80; }
.progress-bar { background: var(--rs-bg-surface-raised); border-radius: 999px; height: 8px; overflow: hidden; } .progress-bar { background: var(--rs-bg-surface-raised); border-radius: 999px; height: 8px; overflow: hidden; }
@ -878,8 +894,9 @@ class FolkCartShop extends HTMLElement {
.empty { text-align: center; padding: 3rem; color: var(--rs-text-muted); font-size: 0.875rem; } .empty { text-align: center; padding: 3rem; color: var(--rs-text-muted); font-size: 0.875rem; }
.loading { text-align: center; padding: 3rem; color: var(--rs-text-secondary); } .loading { text-align: center; padding: 3rem; color: var(--rs-text-secondary); }
@media (max-width: 480px) { @media (max-width: 600px) {
.grid { grid-template-columns: 1fr; } .grid { grid-template-columns: 1fr; }
.catalog-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
.url-input-row { flex-direction: column; } .url-input-row { flex-direction: column; }
} }
`; `;

View File

@ -62,72 +62,10 @@ class FolkGraphViewer extends HTMLElement {
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") { this.loadDemoData(); return; }
this.render(); // Show loading state this.render(); // Show loading state
this.loadData(); // Async — will re-render when data arrives this.loadData(); // Async — will re-render when data arrives
} }
private loadDemoData() {
this.info = { name: "rSpace Community", member_count: 10, company_count: 3, opportunity_count: 0 };
this.workspaces = [
{ name: "Commons DAO", slug: "commons-dao", nodeCount: 5, edgeCount: 4 },
{ name: "Mycelial Lab", slug: "mycelial-lab", nodeCount: 5, edgeCount: 4 },
{ name: "Regenerative Fund", slug: "regenerative-fund", nodeCount: 5, edgeCount: 4 },
];
// Organizations
this.nodes = [
{ id: "org-1", name: "Commons DAO", type: "company", workspace: "Commons DAO", description: "Decentralized governance cooperative" },
{ id: "org-2", name: "Mycelial Lab", type: "company", workspace: "Mycelial Lab", description: "Protocol research collective" },
{ id: "org-3", name: "Regenerative Fund", type: "company", workspace: "Regenerative Fund", description: "Impact funding vehicle" },
// People — Commons DAO
{ id: "p-1", name: "Alice Chen", type: "person", workspace: "Commons DAO", role: "Lead Engineer", location: "Vancouver" },
{ id: "p-2", name: "Bob Nakamura", type: "person", workspace: "Commons DAO", role: "Community Lead", location: "Tokyo" },
{ id: "p-3", name: "Carol Santos", type: "person", workspace: "Commons DAO", role: "Treasury Steward", location: "São Paulo" },
{ id: "p-4", name: "Dave Okafor", type: "person", workspace: "Commons DAO", role: "Governance Facilitator", location: "Lagos" },
// People — Mycelial Lab
{ id: "p-5", name: "Eva Larsson", type: "person", workspace: "Mycelial Lab", role: "Ops Coordinator", location: "Stockholm" },
{ id: "p-6", name: "Frank Müller", type: "person", workspace: "Mycelial Lab", role: "Protocol Designer", location: "Berlin" },
{ id: "p-7", name: "Grace Kim", type: "person", workspace: "Mycelial Lab", role: "Strategy Lead", location: "Seoul" },
// People — Regenerative Fund
{ id: "p-8", name: "Hiro Tanaka", type: "person", workspace: "Regenerative Fund", role: "Research Lead", location: "Osaka" },
{ id: "p-9", name: "Iris Patel", type: "person", workspace: "Regenerative Fund", role: "Developer Relations", location: "Mumbai" },
{ id: "p-10", name: "James Wright", type: "person", workspace: "Regenerative Fund", role: "Security Auditor", location: "London" },
];
// Edges: work_at links + cross-org point_of_contact
this.edges = [
// Work_at — Commons DAO
{ source: "p-1", target: "org-1", type: "work_at" },
{ source: "p-2", target: "org-1", type: "work_at" },
{ source: "p-3", target: "org-1", type: "work_at" },
{ source: "p-4", target: "org-1", type: "work_at" },
// Work_at — Mycelial Lab
{ source: "p-5", target: "org-2", type: "work_at" },
{ source: "p-6", target: "org-2", type: "work_at" },
{ source: "p-7", target: "org-2", type: "work_at" },
// Work_at — Regenerative Fund
{ source: "p-8", target: "org-3", type: "work_at" },
{ source: "p-9", target: "org-3", type: "work_at" },
{ source: "p-10", target: "org-3", type: "work_at" },
// Cross-org point_of_contact edges
{ source: "p-1", target: "p-6", type: "point_of_contact", label: "Alice ↔ Frank" },
{ source: "p-2", target: "p-3", type: "point_of_contact", label: "Bob ↔ Carol" },
{ source: "p-4", target: "p-7", type: "point_of_contact", label: "Dave ↔ Grace" },
];
this.layoutDirty = true;
this.render();
requestAnimationFrame(() => this.fitView());
}
private getApiBase(): string { private getApiBase(): string {
const path = window.location.pathname; const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rnetwork/); const match = path.match(/^(\/[^/]+)?\/rnetwork/);

View File

@ -7,7 +7,7 @@
*/ */
import { Hono } from "hono"; import { Hono } from "hono";
import { renderShell, renderExternalAppShell } from "../../server/shell"; import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { renderLanding } from "./landing"; import { renderLanding } from "./landing";
@ -275,35 +275,27 @@ routes.get("/api/opportunities", async (c) => {
return c.json({ opportunities }); return c.json({ opportunities });
}); });
// ── CRM sub-route — embed Twenty CRM via iframe ── // ── CRM sub-route — API-driven CRM view ──
routes.get("/crm", (c) => { routes.get("/crm", (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html(renderShell({
return c.html(renderExternalAppShell({
title: `${space} — CRM | rSpace`, title: `${space} — CRM | rSpace`,
moduleId: "rnetwork", moduleId: "rnetwork",
spaceSlug: space, spaceSlug: space,
modules: getModuleInfoList(), modules: getModuleInfoList(),
appUrl: "https://crm.rspace.online", body: `<folk-crm-view space="${space}"></folk-crm-view>`,
appName: "Twenty CRM", scripts: `<script type="module" src="/modules/rnetwork/folk-crm-view.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
})); }));
}); });
// ── Page route ── // ── Page route ──
routes.get("/", (c) => { routes.get("/", (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const view = c.req.query("view"); const view = c.req.query("view");
if (view === "app") { if (view === "app") {
return c.html(renderExternalAppShell({ return c.redirect(`/${space}/rnetwork/crm`, 301);
title: `${space} — CRM | rSpace`,
moduleId: "rnetwork",
spaceSlug: space,
modules: getModuleInfoList(),
appUrl: "https://crm.rspace.online",
appName: "Twenty CRM",
}));
} }
return c.html(renderShell({ return c.html(renderShell({
@ -311,7 +303,7 @@ routes.get("/", (c) => {
moduleId: "rnetwork", moduleId: "rnetwork",
spaceSlug: space, spaceSlug: space,
modules: getModuleInfoList(), modules: getModuleInfoList(),
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div> body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="/${space}/rnetwork/crm" class="rapp-nav__btn--app-toggle">Open CRM</a></div>
<folk-graph-viewer space="${space}"></folk-graph-viewer>`, <folk-graph-viewer space="${space}"></folk-graph-viewer>`,
scripts: `<script type="module" src="/modules/rnetwork/folk-graph-viewer.js"></script>`, scripts: `<script type="module" src="/modules/rnetwork/folk-graph-viewer.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`, styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,

View File

@ -1528,16 +1528,19 @@ async function generateSessionToken(userId: string, username: string): Promise<s
aud: CONFIG.allowedOrigins, aud: CONFIG.allowedOrigins,
iat: now, iat: now,
exp: now + CONFIG.sessionDuration, exp: now + CONFIG.sessionDuration,
jti: Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64url'),
username, username,
did: `did:key:${userId.slice(0, 32)}`, did: `did:key:${userId.slice(0, 32)}`,
eid: { eid: {
authLevel: 3, // ELEVATED (fresh WebAuthn) authLevel: 3, // ELEVATED (fresh WebAuthn)
authTime: now,
walletAddress: profile?.walletAddress || null, walletAddress: profile?.walletAddress || null,
capabilities: { capabilities: {
encrypt: true, encrypt: true,
sign: true, sign: true,
wallet: hasWallet, wallet: hasWallet,
}, },
recoveryConfigured: false,
}, },
}; };

View File

@ -8,6 +8,8 @@
import { AuthenticationResult, bufferToBase64url } from './webauthn'; import { AuthenticationResult, bufferToBase64url } from './webauthn';
import { resetLinkedWalletStore } from './linked-wallets'; import { resetLinkedWalletStore } from './linked-wallets';
const ENCRYPTID_SERVER = 'https://auth.rspace.online';
// ============================================================================ // ============================================================================
// TYPES // TYPES
// ============================================================================ // ============================================================================
@ -154,7 +156,8 @@ export class SessionManager {
} }
/** /**
* Initialize session from authentication result * Initialize session from authentication result.
* Exchanges the credential for a server-signed JWT via EncryptID.
*/ */
async createSession( async createSession(
authResult: AuthenticationResult, authResult: AuthenticationResult,
@ -163,38 +166,78 @@ export class SessionManager {
): Promise<SessionState> { ): Promise<SessionState> {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
// Build claims // Get a server-signed JWT by exchanging the credential via EncryptID
const claims: EncryptIDClaims = { let accessToken: string;
iss: 'https://auth.ridentity.online', try {
sub: did, // Step 1: Get a server challenge
aud: [ const startRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/start`, {
'rspace.online', method: 'POST',
'rwallet.online', headers: { 'Content-Type': 'application/json' },
'rvote.online', body: JSON.stringify({ credentialId: authResult.credentialId }),
'rfiles.online', });
'rmaps.online', if (!startRes.ok) throw new Error('Failed to start server auth');
], const { options } = await startRes.json();
iat: now,
exp: now + 15 * 60, // 15 minutes
jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
eid: { // Step 2: Complete auth with credentialId to get signed token
credentialId: authResult.credentialId, const completeRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/complete`, {
authLevel: AuthLevel.ELEVATED, // Fresh WebAuthn method: 'POST',
authTime: now, headers: { 'Content-Type': 'application/json' },
capabilities, body: JSON.stringify({
recoveryConfigured: false, // TODO: Check actual status challenge: options.challenge,
}, credential: { credentialId: authResult.credentialId },
}; }),
});
if (!completeRes.ok) throw new Error('Server auth failed');
const data = await completeRes.json();
if (!data.token) throw new Error('No token in response');
accessToken = data.token;
} catch (err) {
console.warn('EncryptID: Server token exchange failed, using local token', err);
// Fallback to unsigned token if server is unreachable
accessToken = this.createUnsignedToken({
iss: 'https://auth.ridentity.online',
sub: did,
aud: ['rspace.online'],
iat: now,
exp: now + 15 * 60,
jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
eid: {
credentialId: authResult.credentialId,
authLevel: AuthLevel.ELEVATED,
authTime: now,
capabilities,
recoveryConfigured: false,
},
});
}
// In production, tokens would be signed by server // Decode claims from the token (works for both signed and unsigned JWTs)
// For now, we create unsigned tokens for the prototype let claims: EncryptIDClaims;
const accessToken = this.createUnsignedToken(claims); try {
const refreshToken = this.createRefreshToken(did); const payloadB64 = accessToken.split('.')[1];
claims = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/')));
} catch {
// Fallback claims
claims = {
iss: 'https://auth.ridentity.online',
sub: did,
aud: ['rspace.online'],
iat: now,
exp: now + 15 * 60,
jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
eid: {
credentialId: authResult.credentialId,
authLevel: AuthLevel.ELEVATED,
authTime: now,
capabilities,
recoveryConfigured: false,
},
};
}
this.session = { this.session = {
accessToken, accessToken,
refreshToken, refreshToken: accessToken, // Server token serves as both
claims, claims,
lastAuthTime: Date.now(), lastAuthTime: Date.now(),
}; };
@ -206,8 +249,9 @@ export class SessionManager {
this.scheduleRefresh(); this.scheduleRefresh();
console.log('EncryptID: Session created', { console.log('EncryptID: Session created', {
did: did.slice(0, 30) + '...', did: (claims.sub || did).slice(0, 30) + '...',
authLevel: AuthLevel[claims.eid.authLevel], authLevel: AuthLevel[claims.eid?.authLevel ?? AuthLevel.ELEVATED],
signed: !accessToken.endsWith('.'),
}); });
return this.session; return this.session;
@ -436,30 +480,38 @@ export class SessionManager {
} }
private async refreshTokens(): Promise<void> { private async refreshTokens(): Promise<void> {
// In production, this would call the server to refresh tokens
// For the prototype, we just extend the expiration
if (!this.session) return; if (!this.session) return;
const now = Math.floor(Date.now() / 1000); try {
// Call EncryptID server to refresh the token
const res = await fetch(`${ENCRYPTID_SERVER}/api/session/refresh`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.session.accessToken}`,
'Content-Type': 'application/json',
},
});
// Downgrade auth level on refresh (user hasn't re-authenticated) if (!res.ok) throw new Error(`Refresh failed: ${res.status}`);
this.session.claims.eid.authLevel = Math.min( const data = await res.json();
this.session.claims.eid.authLevel, if (!data.token) throw new Error('No token in refresh response');
AuthLevel.STANDARD
);
this.session.claims.iat = now; // Decode new claims
this.session.claims.exp = now + 15 * 60; const payloadB64 = data.token.split('.')[1];
this.session.claims.jti = bufferToBase64url( const claims = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/')));
crypto.getRandomValues(new Uint8Array(16)).buffer
);
this.session.accessToken = this.createUnsignedToken(this.session.claims); this.session.accessToken = data.token;
this.session.refreshToken = data.token;
this.session.claims = claims;
this.persistSession(); this.persistSession();
this.scheduleRefresh(); this.scheduleRefresh();
console.log('EncryptID: Tokens refreshed'); console.log('EncryptID: Tokens refreshed (server-signed)');
} catch (err) {
console.warn('EncryptID: Token refresh failed', err);
// Session will expire naturally; user will need to re-authenticate
}
} }
} }