fix(auth): same-origin passkey proxy + PRF fallback for Safari

- 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) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-11 22:31:40 +00:00
parent 843c8ad682
commit 44cc47ecf1
7 changed files with 145 additions and 66 deletions

View File

@ -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;

View File

@ -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<WSData>({
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<WSData>({
// ── 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;
}

View File

@ -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)

View File

@ -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

View File

@ -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;

View File

@ -288,38 +288,37 @@ export async function authenticatePasskey(
}
}
// Build authentication options
const getOptions: CredentialRequestOptions = {
publicKey: {
// Build base publicKey options (without PRF initially)
const basePublicKey: PublicKeyCredentialRequestOptions = {
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),
},
},
},
},
};
// 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');
try {
const credential = await navigator.credentials.get({
publicKey: {
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 {
// 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),
prf: { eval: { first: new Uint8Array(prfSalt) } },
},
},
},
},
// @ts-ignore - conditional mediation
mediation: 'conditional',
...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),

View File

@ -1,7 +1,7 @@
/// <reference lib="webworker" />
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) => {