merge: passkey Safari fix — same-origin auth proxy + PRF fallback
CI/CD / deploy (push) Failing after 1m43s
Details
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:
commit
9f592ec189
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue