feat(auth): cross-subdomain username hint for passkey SSO
Adds a non-HttpOnly `rspace_hint` cookie scoped to .rspace.online carrying only the public username (+ optional displayName). No JWT, session, or credential crosses origins — WebAuthn RP ID rspace.online still drives the actual ceremony, so moving between space subdomains requires a single passkey tap instead of re-registering an account. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c15462a37b
commit
2ecee5b2e7
|
|
@ -283,6 +283,16 @@ function getAllKnownUsernames(): string[] {
|
||||||
for (const a of accounts) add(a.username);
|
for (const a of accounts) add(a.username);
|
||||||
} catch { /* ignore */ }
|
} 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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,17 @@ import { getVaultManager, resetVaultManager } from '../vault';
|
||||||
import { syncWalletsOnLogin } from '../wallet-sync';
|
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 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
|
const ENCRYPTID_AUTH = ''; // same-origin — avoids cross-origin issues on Safari
|
||||||
|
|
||||||
interface KnownAccount {
|
interface KnownAccount {
|
||||||
|
|
@ -32,21 +39,74 @@ interface KnownAccount {
|
||||||
displayName?: string;
|
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 {
|
try {
|
||||||
return JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || '[]');
|
const raw = decodeURIComponent(match.slice(HINT_COOKIE.length + 1));
|
||||||
} catch { return []; }
|
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 {
|
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
|
accounts.unshift(account); // most recent first
|
||||||
localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts));
|
localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts));
|
||||||
|
writeHintCookie(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeKnownAccount(username: string): void {
|
function removeKnownAccount(username: string): void {
|
||||||
const accounts = getKnownAccounts().filter(a => a.username !== username);
|
const accounts = getKnownAccounts().filter(a => a.username !== username);
|
||||||
localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts));
|
localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts));
|
||||||
|
const hint = readHintCookie();
|
||||||
|
if (hint && hint.username === username) clearHintCookie();
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue