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:
parent
843c8ad682
commit
44cc47ecf1
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue