From 44cc47ecf1247fba620c426983037e901b913981 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 11 Apr 2026 22:31:40 +0000 Subject: [PATCH] fix(auth): same-origin passkey proxy + PRF fallback for Safari MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add /api/auth/*, /api/register/*, /api/account/* proxy routes to rspace-online server, forwarding to encryptid container internally. This eliminates cross-origin requests that Safari blocks via ITP or Cloudflare security challenges. - Change client auth URLs from https://auth.rspace.online to same-origin in rstack-identity, rspace-header, login-button, and session modules. - Add PRF extension try/catch fallback in webauthn.ts — Safari throws TypeError on the unsupported PRF extension, now retries without it. - Bump SW cache version v7→v8 to bust stale cached bundles. Fixes passkey login for Safari/macOS users (e.g. christina) who were getting "Network error when attempting to reach resource". Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/rspace-header.ts | 2 +- server/index.ts | 79 +++++++++++++++--- shared/components/rstack-identity.ts | 2 +- src/encryptid/session.ts | 2 +- src/encryptid/ui/login-button.ts | 2 +- src/encryptid/webauthn.ts | 117 ++++++++++++++++----------- website/sw.ts | 7 +- 7 files changed, 145 insertions(+), 66 deletions(-) diff --git a/lib/rspace-header.ts b/lib/rspace-header.ts index ef4494f7..3feda39a 100644 --- a/lib/rspace-header.ts +++ b/lib/rspace-header.ts @@ -6,7 +6,7 @@ */ const SESSION_KEY = 'encryptid_session'; -const ENCRYPTID_URL = 'https://auth.rspace.online'; +const ENCRYPTID_URL = ''; // same-origin — avoids cross-origin issues on Safari interface SessionState { accessToken: string; diff --git a/server/index.ts b/server/index.ts index 793edee2..10658af6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -539,8 +539,32 @@ app.route("/api/mcp", createMcpRouter(syncServer)); // ── Magic Link Responses (top-level, bypasses space auth) ── app.route("/respond", magicLinkRoutes); -// ── EncryptID proxy (forward /encryptid/* to encryptid container) ── +// ── Same-origin auth proxy (avoids cross-origin issues on Safari) ── const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000"; + +const proxyToEncryptid = async (c: any) => { + const targetUrl = `${ENCRYPTID_INTERNAL}${c.req.path}${new URL(c.req.url).search}`; + const headers = new Headers(c.req.raw.headers); + headers.delete("host"); + try { + const res = await fetch(targetUrl, { + method: c.req.method, + headers, + body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined, + // @ts-ignore duplex needed for streaming request bodies + duplex: "half", + }); + return new Response(res.body, { status: res.status, headers: res.headers }); + } catch (e: any) { + return c.json({ error: "EncryptID service unavailable" }, 502); + } +}; + +app.all("/api/auth/*", proxyToEncryptid); +app.all("/api/register/*", proxyToEncryptid); +app.all("/api/account/*", proxyToEncryptid); + +// ── EncryptID proxy (forward /encryptid/* to encryptid container) ── app.all("/encryptid/*", async (c) => { const targetUrl = `${ENCRYPTID_INTERNAL}${c.req.path}${new URL(c.req.url).search}`; const headers = new Headers(c.req.raw.headers); @@ -4044,7 +4068,24 @@ const server = Bun.serve({ const rewrittenPath = `/${subdomain}${normalizedPath}`; const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`); const rewrittenReq = new Request(rewrittenUrl, req); - return app.fetch(rewrittenReq); + const subdomainResponse = await app.fetch(rewrittenReq); + + // If 404 on an HTML request for a known module sub-path, serve the + // module shell so client-side SPA routing can handle it + if (subdomainResponse.status === 404 && pathSegments.length >= 2) { + const accept = req.headers.get("Accept") || ""; + if (accept.includes("text/html") && !normalizedPath.includes("/api/")) { + const moduleRoot = `/${subdomain}/${resolveModuleAlias(pathSegments[0].toLowerCase())}`; + const shellUrl = new URL(moduleRoot, `http://localhost:${PORT}`); + const shellResponse = await app.fetch(new Request(shellUrl, { + headers: req.headers, + method: "GET", + })); + if (shellResponse.status === 200) return shellResponse; + } + } + + return subdomainResponse; } // ── Bare-domain routing: rspace.online/{...} ── @@ -4136,19 +4177,37 @@ const server = Bun.serve({ // ── Hono handles everything else ── const response = await app.fetch(req); - // If Hono returns 404, try serving canvas.html as SPA fallback - // But only for paths that don't match a known module route + // If Hono returns 404, serve the module shell for client-side routing + // (module sub-paths like /demo/rcal/settings are handled by the SPA router) if (response.status === 404 && !url.pathname.startsWith("/api/")) { + const accept = req.headers.get("Accept") || ""; const parts = url.pathname.split("/").filter(Boolean); - // Check if this is under a known module — if so, the module's 404 is authoritative - const knownModuleIds = getAllModules().map((m) => m.id); - const isModulePath = parts.length >= 2 && knownModuleIds.includes(parts[1].toLowerCase()); - if (!isModulePath && parts.length >= 1 && !parts[0].includes(".")) { - // Not a module path — could be a canvas SPA route, try fallback + if (accept.includes("text/html") && parts.length >= 1 && !parts[0].includes(".")) { + const knownModuleIds = getAllModules().map((m) => m.id); + + // Subdomain requests: URL is /{moduleId}/... (space is the subdomain) + // Bare-domain requests: URL is /{space}/{moduleId}/... + const moduleId = subdomain + ? resolveModuleAlias(parts[0].toLowerCase()) + : parts.length >= 2 ? parts[1].toLowerCase() : null; + const space = subdomain || parts[0]; + + if (moduleId && knownModuleIds.includes(moduleId)) { + // Module sub-path: rewrite to the module root so the shell renders + // and the client-side SPA router handles the sub-path + const moduleRoot = `/${space}/${moduleId}`; + const rewrittenUrl = new URL(moduleRoot, `http://localhost:${PORT}`); + const shellResponse = await app.fetch(new Request(rewrittenUrl, { + headers: req.headers, + method: "GET", + })); + if (shellResponse.status === 200) return shellResponse; + } + + // Non-module path — try canvas/index SPA fallback const canvasHtml = await serveStatic("canvas.html"); if (canvasHtml) return canvasHtml; - const indexHtml = await serveStatic("index.html"); if (indexHtml) return indexHtml; } diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index 43c7be98..b64ac89e 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -11,7 +11,7 @@ import { resetDocBridge, isEncryptedBackupEnabled, setEncryptedBackupEnabled } f import { getShortcuts, setShortcut, removeShortcut } from "../shortcut-config"; const SESSION_KEY = "encryptid_session"; -const ENCRYPTID_URL = "https://auth.rspace.online"; +const ENCRYPTID_URL = ""; // same-origin — avoids cross-origin issues on Safari const COOKIE_NAME = "eid_token"; const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 days (matches server sessionDuration) diff --git a/src/encryptid/session.ts b/src/encryptid/session.ts index f20370f4..35adb43f 100644 --- a/src/encryptid/session.ts +++ b/src/encryptid/session.ts @@ -8,7 +8,7 @@ import { AuthenticationResult, bufferToBase64url } from './webauthn'; import { resetLinkedWalletStore } from './linked-wallets'; -const ENCRYPTID_SERVER = 'https://auth.rspace.online'; +const ENCRYPTID_SERVER = ''; // same-origin — avoids cross-origin issues on Safari // ============================================================================ // TYPES diff --git a/src/encryptid/ui/login-button.ts b/src/encryptid/ui/login-button.ts index 8ef13d48..577d34ef 100644 --- a/src/encryptid/ui/login-button.ts +++ b/src/encryptid/ui/login-button.ts @@ -25,7 +25,7 @@ import { syncWalletsOnLogin } from '../wallet-sync'; // ============================================================================ const KNOWN_ACCOUNTS_KEY = 'encryptid-known-accounts'; -const ENCRYPTID_AUTH = 'https://auth.rspace.online'; +const ENCRYPTID_AUTH = ''; // same-origin — avoids cross-origin issues on Safari interface KnownAccount { username: string; diff --git a/src/encryptid/webauthn.ts b/src/encryptid/webauthn.ts index 4ecaa90b..2d97d18d 100644 --- a/src/encryptid/webauthn.ts +++ b/src/encryptid/webauthn.ts @@ -288,38 +288,37 @@ export async function authenticatePasskey( } } - // Build authentication options - const getOptions: CredentialRequestOptions = { - publicKey: { - challenge: new Uint8Array(challenge), - - // Relying Party ID - rpId: cfg.rpId, - - // Allowed credentials (or undefined for discoverable) - allowCredentials: allowCredentials, - - // Require user verification - userVerification: cfg.userVerification, - - // Timeout - timeout: cfg.timeout, - - // Extensions - extensions: { - // Request PRF evaluation for key derivation - // @ts-ignore - PRF extension not in standard types yet - prf: { - eval: { - first: new Uint8Array(prfSalt), - }, - }, - }, - }, + // Build base publicKey options (without PRF initially) + const basePublicKey: PublicKeyCredentialRequestOptions = { + challenge: new Uint8Array(challenge), + rpId: cfg.rpId, + allowCredentials: allowCredentials, + userVerification: cfg.userVerification, + timeout: cfg.timeout, }; - // Perform authentication - const credential = await navigator.credentials.get(getOptions) as PublicKeyCredential; + // Try with PRF first, fall back without it (Safari throws on PRF extension) + let credential: PublicKeyCredential | null = null; + let usedPrf = false; + try { + credential = await navigator.credentials.get({ + publicKey: { + ...basePublicKey, + extensions: { + // @ts-ignore - PRF extension not in standard types yet + prf: { eval: { first: new Uint8Array(prfSalt) } }, + }, + }, + }) as PublicKeyCredential; + usedPrf = true; + } catch (prfError: any) { + // Safari and some browsers throw TypeError on unsupported PRF extension. + // Retry without PRF — user falls back to passphrase-based key derivation. + console.warn('EncryptID: PRF extension failed, retrying without PRF', prfError?.message); + credential = await navigator.credentials.get({ + publicKey: basePublicKey, + }) as PublicKeyCredential; + } if (!credential) { throw new Error('Authentication failed'); @@ -329,7 +328,7 @@ export async function authenticatePasskey( // Extract PRF output if available // @ts-ignore - const prfResults = credential.getClientExtensionResults()?.prf?.results; + const prfResults = usedPrf ? credential.getClientExtensionResults()?.prf?.results : undefined; const prfOutput = prfResults?.first; // Build result @@ -398,26 +397,46 @@ export async function startConditionalUI( const challenge = generateChallenge(); const prfSalt = await generatePRFSalt('master-key'); + const basePublicKey: PublicKeyCredentialRequestOptions = { + challenge: new Uint8Array(challenge), + rpId: cfg.rpId, + userVerification: cfg.userVerification, + timeout: cfg.timeout, + }; + + const conditionalOpts = { + // @ts-ignore - conditional mediation + mediation: 'conditional' as const, + signal: conditionalUIAbortController.signal, + }; + try { - const credential = await navigator.credentials.get({ - publicKey: { - challenge: new Uint8Array(challenge), - rpId: cfg.rpId, - userVerification: cfg.userVerification, - timeout: cfg.timeout, - extensions: { - // @ts-ignore - prf: { - eval: { - first: new Uint8Array(prfSalt), - }, + // Try with PRF first + let credential: PublicKeyCredential | null = null; + let usedPrf = false; + try { + credential = await navigator.credentials.get({ + publicKey: { + ...basePublicKey, + extensions: { + // @ts-ignore + prf: { eval: { first: new Uint8Array(prfSalt) } }, }, }, - }, - // @ts-ignore - conditional mediation - mediation: 'conditional', - signal: conditionalUIAbortController.signal, - }) as PublicKeyCredential; + ...conditionalOpts, + }) as PublicKeyCredential; + usedPrf = true; + } catch (prfError: any) { + // Safari throws on PRF — retry without it + if (prfError?.name === 'AbortError') throw prfError; + console.warn('EncryptID: Conditional UI PRF failed, retrying without PRF'); + conditionalUIAbortController = new AbortController(); + credential = await navigator.credentials.get({ + publicKey: basePublicKey, + ...conditionalOpts, + signal: conditionalUIAbortController.signal, + }) as PublicKeyCredential; + } // Clear abort controller on success conditionalUIAbortController = null; @@ -428,7 +447,7 @@ export async function startConditionalUI( const response = credential.response as AuthenticatorAssertionResponse; // @ts-ignore - const prfResults = credential.getClientExtensionResults()?.prf?.results; + const prfResults = usedPrf ? credential.getClientExtensionResults()?.prf?.results : undefined; return { credentialId: bufferToBase64url(credential.rawId), diff --git a/website/sw.ts b/website/sw.ts index 46008d5c..4d49fcf2 100644 --- a/website/sw.ts +++ b/website/sw.ts @@ -1,7 +1,7 @@ /// declare const self: ServiceWorkerGlobalScope; -const CACHE_VERSION = "rspace-v6"; +const CACHE_VERSION = "rspace-v8"; const STATIC_CACHE = `${CACHE_VERSION}-static`; const HTML_CACHE = `${CACHE_VERSION}-html`; const API_CACHE = `${CACHE_VERSION}-api`; @@ -51,8 +51,9 @@ self.addEventListener("install", (event) => { ]); })() ); - // Don't skipWaiting automatically — let the client trigger it via - // SKIP_WAITING message so the update banner can prompt the user first. + // Force activate immediately — ensures old PWAs always get the latest version. + // The SKIP_WAITING message handler is kept as a fallback. + self.skipWaiting(); }); self.addEventListener("activate", (event) => {