merge: passkey Safari fix — same-origin auth proxy + PRF fallback
CI/CD / deploy (push) Failing after 1m43s Details

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-11 22:32:11 +00:00
commit 9f592ec189
7 changed files with 145 additions and 66 deletions

View File

@ -6,7 +6,7 @@
*/ */
const SESSION_KEY = 'encryptid_session'; 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 { interface SessionState {
accessToken: string; accessToken: string;

View File

@ -539,8 +539,32 @@ app.route("/api/mcp", createMcpRouter(syncServer));
// ── Magic Link Responses (top-level, bypasses space auth) ── // ── Magic Link Responses (top-level, bypasses space auth) ──
app.route("/respond", magicLinkRoutes); 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 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) => { app.all("/encryptid/*", async (c) => {
const targetUrl = `${ENCRYPTID_INTERNAL}${c.req.path}${new URL(c.req.url).search}`; const targetUrl = `${ENCRYPTID_INTERNAL}${c.req.path}${new URL(c.req.url).search}`;
const headers = new Headers(c.req.raw.headers); const headers = new Headers(c.req.raw.headers);
@ -4044,7 +4068,24 @@ const server = Bun.serve<WSData>({
const rewrittenPath = `/${subdomain}${normalizedPath}`; const rewrittenPath = `/${subdomain}${normalizedPath}`;
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`); const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
const rewrittenReq = new Request(rewrittenUrl, req); 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/{...} ── // ── Bare-domain routing: rspace.online/{...} ──
@ -4136,19 +4177,37 @@ const server = Bun.serve<WSData>({
// ── Hono handles everything else ── // ── Hono handles everything else ──
const response = await app.fetch(req); const response = await app.fetch(req);
// If Hono returns 404, try serving canvas.html as SPA fallback // If Hono returns 404, serve the module shell for client-side routing
// But only for paths that don't match a known module route // (module sub-paths like /demo/rcal/settings are handled by the SPA router)
if (response.status === 404 && !url.pathname.startsWith("/api/")) { if (response.status === 404 && !url.pathname.startsWith("/api/")) {
const accept = req.headers.get("Accept") || "";
const parts = url.pathname.split("/").filter(Boolean); 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(".")) { if (accept.includes("text/html") && parts.length >= 1 && !parts[0].includes(".")) {
// Not a module path — could be a canvas SPA route, try fallback 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"); const canvasHtml = await serveStatic("canvas.html");
if (canvasHtml) return canvasHtml; if (canvasHtml) return canvasHtml;
const indexHtml = await serveStatic("index.html"); const indexHtml = await serveStatic("index.html");
if (indexHtml) return indexHtml; if (indexHtml) return indexHtml;
} }

View File

@ -11,7 +11,7 @@ import { resetDocBridge, isEncryptedBackupEnabled, setEncryptedBackupEnabled } f
import { getShortcuts, setShortcut, removeShortcut } from "../shortcut-config"; import { getShortcuts, setShortcut, removeShortcut } from "../shortcut-config";
const SESSION_KEY = "encryptid_session"; 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_NAME = "eid_token";
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 days (matches server sessionDuration) 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 { AuthenticationResult, bufferToBase64url } from './webauthn';
import { resetLinkedWalletStore } from './linked-wallets'; import { resetLinkedWalletStore } from './linked-wallets';
const ENCRYPTID_SERVER = 'https://auth.rspace.online'; const ENCRYPTID_SERVER = ''; // same-origin — avoids cross-origin issues on Safari
// ============================================================================ // ============================================================================
// TYPES // TYPES

View File

@ -25,7 +25,7 @@ import { syncWalletsOnLogin } from '../wallet-sync';
// ============================================================================ // ============================================================================
const KNOWN_ACCOUNTS_KEY = 'encryptid-known-accounts'; 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 { interface KnownAccount {
username: string; username: string;

View File

@ -288,38 +288,37 @@ export async function authenticatePasskey(
} }
} }
// Build authentication options // Build base publicKey options (without PRF initially)
const getOptions: CredentialRequestOptions = { const basePublicKey: PublicKeyCredentialRequestOptions = {
publicKey: { challenge: new Uint8Array(challenge),
challenge: new Uint8Array(challenge), rpId: cfg.rpId,
allowCredentials: allowCredentials,
// Relying Party ID userVerification: cfg.userVerification,
rpId: cfg.rpId, timeout: cfg.timeout,
// 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 // Try with PRF first, fall back without it (Safari throws on PRF extension)
const credential = await navigator.credentials.get(getOptions) as PublicKeyCredential; 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) { if (!credential) {
throw new Error('Authentication failed'); throw new Error('Authentication failed');
@ -329,7 +328,7 @@ export async function authenticatePasskey(
// Extract PRF output if available // Extract PRF output if available
// @ts-ignore // @ts-ignore
const prfResults = credential.getClientExtensionResults()?.prf?.results; const prfResults = usedPrf ? credential.getClientExtensionResults()?.prf?.results : undefined;
const prfOutput = prfResults?.first; const prfOutput = prfResults?.first;
// Build result // Build result
@ -398,26 +397,46 @@ export async function startConditionalUI(
const challenge = generateChallenge(); const challenge = generateChallenge();
const prfSalt = await generatePRFSalt('master-key'); 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 { try {
const credential = await navigator.credentials.get({ // Try with PRF first
publicKey: { let credential: PublicKeyCredential | null = null;
challenge: new Uint8Array(challenge), let usedPrf = false;
rpId: cfg.rpId, try {
userVerification: cfg.userVerification, credential = await navigator.credentials.get({
timeout: cfg.timeout, publicKey: {
extensions: { ...basePublicKey,
// @ts-ignore extensions: {
prf: { // @ts-ignore
eval: { prf: { eval: { first: new Uint8Array(prfSalt) } },
first: new Uint8Array(prfSalt),
},
}, },
}, },
}, ...conditionalOpts,
// @ts-ignore - conditional mediation }) as PublicKeyCredential;
mediation: 'conditional', usedPrf = true;
signal: conditionalUIAbortController.signal, } catch (prfError: any) {
}) as PublicKeyCredential; // 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 // Clear abort controller on success
conditionalUIAbortController = null; conditionalUIAbortController = null;
@ -428,7 +447,7 @@ export async function startConditionalUI(
const response = credential.response as AuthenticatorAssertionResponse; const response = credential.response as AuthenticatorAssertionResponse;
// @ts-ignore // @ts-ignore
const prfResults = credential.getClientExtensionResults()?.prf?.results; const prfResults = usedPrf ? credential.getClientExtensionResults()?.prf?.results : undefined;
return { return {
credentialId: bufferToBase64url(credential.rawId), credentialId: bufferToBase64url(credential.rawId),

View File

@ -1,7 +1,7 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope; declare const self: ServiceWorkerGlobalScope;
const CACHE_VERSION = "rspace-v6"; const CACHE_VERSION = "rspace-v8";
const STATIC_CACHE = `${CACHE_VERSION}-static`; const STATIC_CACHE = `${CACHE_VERSION}-static`;
const HTML_CACHE = `${CACHE_VERSION}-html`; const HTML_CACHE = `${CACHE_VERSION}-html`;
const API_CACHE = `${CACHE_VERSION}-api`; const API_CACHE = `${CACHE_VERSION}-api`;
@ -51,8 +51,9 @@ self.addEventListener("install", (event) => {
]); ]);
})() })()
); );
// Don't skipWaiting automatically — let the client trigger it via // Force activate immediately — ensures old PWAs always get the latest version.
// SKIP_WAITING message so the update banner can prompt the user first. // The SKIP_WAITING message handler is kept as a fallback.
self.skipWaiting();
}); });
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {