diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index b465d53b..c9fcc26a 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -283,6 +283,16 @@ function getAllKnownUsernames(): string[] { for (const a of accounts) add(a.username); } catch { /* ignore */ } + // 5. Cross-subdomain hint cookie on .rspace.online — fresh subdomain has no + // localStorage yet; login-button writes username here on successful auth. + try { + const match = document.cookie.split(/;\s*/).find(c => c.startsWith("rspace_hint=")); + if (match) { + const parsed = JSON.parse(decodeURIComponent(match.slice("rspace_hint=".length))); + if (parsed?.username) add(parsed.username); + } + } catch { /* ignore */ } + return result; } diff --git a/src/encryptid/ui/login-button.ts b/src/encryptid/ui/login-button.ts index 577d34ef..be07ae58 100644 --- a/src/encryptid/ui/login-button.ts +++ b/src/encryptid/ui/login-button.ts @@ -21,10 +21,17 @@ import { getVaultManager, resetVaultManager } from '../vault'; import { syncWalletsOnLogin } from '../wallet-sync'; // ============================================================================ -// KNOWN ACCOUNTS (localStorage) +// KNOWN ACCOUNTS (localStorage + .rspace.online hint cookie) // ============================================================================ +// +// Cross-subdomain UX hint ONLY. The cookie carries username + optional +// displayName so the passkey button can say "Sign in as jeff" on a freshly +// visited subdomain. No session/JWT/credential ever crosses origins — the +// actual auth is still a WebAuthn ceremony bound to RP ID rspace.online. const KNOWN_ACCOUNTS_KEY = 'encryptid-known-accounts'; +const HINT_COOKIE = 'rspace_hint'; +const HINT_TTL_SECONDS = 60 * 60 * 24 * 30; // 30 days const ENCRYPTID_AUTH = ''; // same-origin — avoids cross-origin issues on Safari interface KnownAccount { @@ -32,21 +39,74 @@ interface KnownAccount { displayName?: string; } -function getKnownAccounts(): KnownAccount[] { +function parentDomainForCookie(): string | null { + if (typeof location === 'undefined') return null; + const host = location.hostname; + // Share across *.rspace.online. Localhost / other hosts → per-origin only. + if (host === 'rspace.online' || host.endsWith('.rspace.online')) return 'rspace.online'; + return null; +} + +function readHintCookie(): KnownAccount | null { + if (typeof document === 'undefined') return null; + const match = document.cookie.split(/;\s*/).find(c => c.startsWith(`${HINT_COOKIE}=`)); + if (!match) return null; try { - return JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || '[]'); - } catch { return []; } + const raw = decodeURIComponent(match.slice(HINT_COOKIE.length + 1)); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.username === 'string') { + return { username: parsed.username, displayName: parsed.displayName }; + } + } catch { /* malformed — ignore */ } + return null; +} + +function writeHintCookie(account: KnownAccount): void { + const domain = parentDomainForCookie(); + if (!domain) return; // dev / non-rspace host: skip cross-origin hint + const value = encodeURIComponent(JSON.stringify({ + username: account.username, + ...(account.displayName ? { displayName: account.displayName } : {}), + })); + document.cookie = `${HINT_COOKIE}=${value}; Domain=.${domain}; Path=/; Max-Age=${HINT_TTL_SECONDS}; Secure; SameSite=Lax`; +} + +function clearHintCookie(): void { + const domain = parentDomainForCookie(); + if (!domain) return; + document.cookie = `${HINT_COOKIE}=; Domain=.${domain}; Path=/; Max-Age=0; Secure; SameSite=Lax`; +} + +function getKnownAccounts(): KnownAccount[] { + let accounts: KnownAccount[] = []; + try { + accounts = JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || '[]'); + } catch { accounts = []; } + + // Fresh subdomain → localStorage empty; fall back to cross-subdomain hint. + if (accounts.length === 0) { + const hint = readHintCookie(); + if (hint) accounts = [hint]; + } + return accounts; } function addKnownAccount(account: KnownAccount): void { - const accounts = getKnownAccounts().filter(a => a.username !== account.username); + const existing: KnownAccount[] = (() => { + try { return JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || '[]'); } + catch { return []; } + })(); + const accounts = existing.filter(a => a.username !== account.username); accounts.unshift(account); // most recent first localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts)); + writeHintCookie(account); } function removeKnownAccount(username: string): void { const accounts = getKnownAccounts().filter(a => a.username !== username); localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts)); + const hint = readHintCookie(); + if (hint && hint.username === username) clearHintCookie(); } function escapeHtml(s: string): string {