Merge branch 'dev': cross-subdomain passkey hint
CI/CD / deploy (push) Successful in 3m3s Details

This commit is contained in:
Jeff Emmett 2026-04-17 12:05:36 -04:00
commit be762ba2e8
2 changed files with 75 additions and 5 deletions

View File

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

View File

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