diff --git a/compute-js/src/authPersistCookie.js b/compute-js/src/authPersistCookie.js new file mode 100644 index 00000000..2347755f --- /dev/null +++ b/compute-js/src/authPersistCookie.js @@ -0,0 +1,69 @@ +// ABOUTME: Edge handler that emits Set-Cookie with Domain=.divine.video for the SPA. +// ABOUTME: Server-set fallback for cross-subdomain auth cookies (document.cookie can fail silently). + +// Cookies the SPA is allowed to ask the edge to set/clear on its behalf. +// Anything outside this list is rejected — keeps this endpoint from being +// abused as a generic cookie-jar for the apex domain. +const PERSIST_COOKIE_ALLOWED = new Set(['nostr_login', 'divine_jwt']); +const PERSIST_COOKIE_MAX_VALUE = 3500; +const PERSIST_COOKIE_MAX_AGE_SECS = 60 * 60 * 24 * 365; +const JWT_DEFAULT_MAX_AGE_SECS = 60 * 60 * 24 * 7; + +export function cookieDomainFor(hostname) { + if (!hostname) return null; + const host = hostname.split(':')[0].toLowerCase(); + if (host === 'divine.video' || host.endsWith('.divine.video')) return '.divine.video'; + if (host === 'dvines.org' || host.endsWith('.dvines.org')) return '.dvines.org'; + return null; +} + +export async function handleAuthPersistCookie(request, hostname) { + const isPost = request.method === 'POST'; + const isDelete = request.method === 'DELETE'; + if (!isPost && !isDelete) { + return new Response('Method Not Allowed', { status: 405, headers: { Allow: 'POST, DELETE' } }); + } + + const domain = cookieDomainFor(hostname); + if (!domain) { + return new Response('No cross-subdomain domain for this host', { status: 400 }); + } + + let body; + try { + body = await request.json(); + } catch { + return new Response('Invalid JSON', { status: 400 }); + } + + const name = typeof body?.name === 'string' ? body.name : ''; + if (!PERSIST_COOKIE_ALLOWED.has(name)) { + return new Response('Cookie name not allowed', { status: 403 }); + } + + let setCookie; + if (isDelete) { + // Mirror the two-write clear in crossSubdomainAuth.ts so we wipe both the + // domain-scoped cookie and any host-only sibling that may have stuck around. + setCookie = `${name}=; Domain=${domain}; Path=/; Max-Age=0; SameSite=Lax; Secure`; + } else { + const value = typeof body?.value === 'string' ? body.value : ''; + // base64-only — values come from btoa(JSON.stringify(...)) on the SPA side. + if (!/^[A-Za-z0-9+/=]{1,}$/.test(value) || value.length > PERSIST_COOKIE_MAX_VALUE) { + return new Response('Invalid cookie value', { status: 400 }); + } + let maxAge = Number.isFinite(body?.maxAge) ? Math.floor(body.maxAge) : 0; + if (maxAge <= 0 || maxAge > PERSIST_COOKIE_MAX_AGE_SECS) { + maxAge = name === 'divine_jwt' ? JWT_DEFAULT_MAX_AGE_SECS : PERSIST_COOKIE_MAX_AGE_SECS; + } + setCookie = `${name}=${value}; Domain=${domain}; Path=/; Max-Age=${maxAge}; SameSite=Lax; Secure`; + } + + return new Response(null, { + status: 204, + headers: { + 'Set-Cookie': setCookie, + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/compute-js/src/authPersistCookie.test.ts b/compute-js/src/authPersistCookie.test.ts new file mode 100644 index 00000000..8738fb7d --- /dev/null +++ b/compute-js/src/authPersistCookie.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; + +import { cookieDomainFor, handleAuthPersistCookie } from './authPersistCookie.js'; + +function jsonRequest(method: string, body: unknown): Request { + return new Request('https://divine.video/api/auth/persist-cookie', { + method, + headers: { 'Content-Type': 'application/json' }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }); +} + +describe('cookieDomainFor', () => { + it('returns .divine.video for apex and subdomains', () => { + expect(cookieDomainFor('divine.video')).toBe('.divine.video'); + expect(cookieDomainFor('alice.divine.video')).toBe('.divine.video'); + expect(cookieDomainFor('Alice.Divine.Video')).toBe('.divine.video'); + expect(cookieDomainFor('divine.video:443')).toBe('.divine.video'); + }); + + it('returns .dvines.org for staging', () => { + expect(cookieDomainFor('dvines.org')).toBe('.dvines.org'); + expect(cookieDomainFor('staging.dvines.org')).toBe('.dvines.org'); + }); + + it('returns null for unknown hosts', () => { + expect(cookieDomainFor('localhost')).toBeNull(); + expect(cookieDomainFor('example.com')).toBeNull(); + expect(cookieDomainFor('')).toBeNull(); + }); +}); + +describe('handleAuthPersistCookie', () => { + it('sets divine_jwt cookie with Domain=.divine.video', async () => { + const value = btoa(JSON.stringify({ token: 'eyJ.x' })); + const res = await handleAuthPersistCookie( + jsonRequest('POST', { name: 'divine_jwt', value, maxAge: 86400 }), + 'alice.divine.video', + ); + expect(res.status).toBe(204); + const setCookie = res.headers.get('Set-Cookie')!; + expect(setCookie).toContain(`divine_jwt=${value}`); + expect(setCookie).toContain('Domain=.divine.video'); + expect(setCookie).toContain('Path=/'); + expect(setCookie).toContain('Max-Age=86400'); + expect(setCookie).toContain('SameSite=Lax'); + expect(setCookie).toContain('Secure'); + }); + + it('sets nostr_login cookie with .dvines.org for staging', async () => { + const value = btoa('xx'); + const res = await handleAuthPersistCookie( + jsonRequest('POST', { name: 'nostr_login', value, maxAge: 60 * 60 * 24 * 365 }), + 'staging.dvines.org', + ); + expect(res.status).toBe(204); + expect(res.headers.get('Set-Cookie')).toContain('Domain=.dvines.org'); + }); + + it('emits a Max-Age=0 cookie on DELETE', async () => { + const res = await handleAuthPersistCookie( + jsonRequest('DELETE', { name: 'divine_jwt' }), + 'divine.video', + ); + expect(res.status).toBe(204); + const setCookie = res.headers.get('Set-Cookie')!; + expect(setCookie).toMatch(/^divine_jwt=; /); + expect(setCookie).toContain('Max-Age=0'); + expect(setCookie).toContain('Domain=.divine.video'); + }); + + it('rejects unknown cookie names', async () => { + const res = await handleAuthPersistCookie( + jsonRequest('POST', { name: 'admin_token', value: btoa('x'), maxAge: 60 }), + 'divine.video', + ); + expect(res.status).toBe(403); + }); + + it('rejects non-base64 values', async () => { + const res = await handleAuthPersistCookie( + jsonRequest('POST', { name: 'divine_jwt', value: 'not;base64!', maxAge: 60 }), + 'divine.video', + ); + expect(res.status).toBe(400); + }); + + it('rejects values that exceed the size cap', async () => { + const value = 'A'.repeat(4000); + const res = await handleAuthPersistCookie( + jsonRequest('POST', { name: 'divine_jwt', value, maxAge: 60 }), + 'divine.video', + ); + expect(res.status).toBe(400); + }); + + it('rejects unsupported methods', async () => { + const res = await handleAuthPersistCookie( + new Request('https://divine.video/api/auth/persist-cookie', { method: 'GET' }), + 'divine.video', + ); + expect(res.status).toBe(405); + expect(res.headers.get('Allow')).toBe('POST, DELETE'); + }); + + it('rejects requests on hosts without a known cross-subdomain domain', async () => { + const value = btoa('x'); + const res = await handleAuthPersistCookie( + jsonRequest('POST', { name: 'divine_jwt', value, maxAge: 60 }), + 'localhost', + ); + expect(res.status).toBe(400); + }); + + it('rejects malformed JSON', async () => { + const res = await handleAuthPersistCookie( + jsonRequest('POST', '{not json'), + 'divine.video', + ); + expect(res.status).toBe(400); + }); + + it('caps maxAge at 1 year and falls back to a default when missing', async () => { + const value = btoa('y'); + + const tooBig = await handleAuthPersistCookie( + jsonRequest('POST', { name: 'divine_jwt', value, maxAge: 60 * 60 * 24 * 365 * 2 }), + 'divine.video', + ); + expect(tooBig.status).toBe(204); + expect(tooBig.headers.get('Set-Cookie')).toContain(`Max-Age=${60 * 60 * 24 * 7}`); + + const missing = await handleAuthPersistCookie( + jsonRequest('POST', { name: 'nostr_login', value }), + 'divine.video', + ); + expect(missing.status).toBe(204); + expect(missing.headers.get('Set-Cookie')).toContain(`Max-Age=${60 * 60 * 24 * 365}`); + }); + + it('sets Cache-Control: no-store on responses', async () => { + const res = await handleAuthPersistCookie( + jsonRequest('DELETE', { name: 'nostr_login' }), + 'divine.video', + ); + expect(res.headers.get('Cache-Control')).toBe('no-store'); + }); +}); diff --git a/compute-js/src/index.js b/compute-js/src/index.js index cf0a7e43..c122f109 100644 --- a/compute-js/src/index.js +++ b/compute-js/src/index.js @@ -8,6 +8,7 @@ import { SecretStore } from 'fastly:secret-store'; import { PublisherServer } from '@fastly/compute-js-static-publish'; import rc from '../static-publish.rc.js'; import { buildFunnelcakeUrl, getFunnelcakeOriginForApiHost } from './funnelcakeOrigin.js'; +import { handleAuthPersistCookie } from './authPersistCookie.js'; import { isJsonWellKnownPath, shouldServeWellKnownBeforeWwwRedirect } from './wellKnownPaths.js'; const publisherServer = PublisherServer.fromStaticPublishRc(rc); @@ -175,6 +176,13 @@ async function handleRequest(event) { return await handleReport(request); } + // 7a. Server-set cross-subdomain auth cookie. Browsers handle Set-Cookie far + // more reliably than client-side document.cookie writes, which silently fail + // in some contexts (Brave, Firefox ETP-Strict, etc). Same-origin so no CORS. + if (url.pathname === '/api/auth/persist-cookie') { + return await handleAuthPersistCookie(request, hostnameToUse); + } + // 7b. Proxy RSS feed requests to the relay backend (serves application/rss+xml) if (url.pathname.startsWith('/feed/') || url.pathname === '/feed') { console.log('Proxying RSS feed request to relay:', url.pathname); diff --git a/src/components/auth/AccountSwitcher.test.tsx b/src/components/auth/AccountSwitcher.test.tsx index 039917e2..b82fbf2e 100644 --- a/src/components/auth/AccountSwitcher.test.tsx +++ b/src/components/auth/AccountSwitcher.test.tsx @@ -3,6 +3,7 @@ import { render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const { + mockClearJwtCookie, mockClearLoginCookie, mockClearSession, mockNavigate, @@ -10,6 +11,7 @@ const { mockSetLogin, mockUseLoggedInAccounts, } = vi.hoisted(() => ({ + mockClearJwtCookie: vi.fn(), mockClearLoginCookie: vi.fn(), mockClearSession: vi.fn(), mockNavigate: vi.fn(), @@ -40,6 +42,7 @@ vi.mock('@/hooks/useDivineSession', () => ({ vi.mock('@/lib/crossSubdomainAuth', () => ({ clearLoginCookie: mockClearLoginCookie, + clearJwtCookie: mockClearJwtCookie, })); vi.mock('@/components/ui/dropdown-menu.tsx', () => ({ @@ -77,6 +80,7 @@ import { AccountSwitcher } from './AccountSwitcher'; describe('AccountSwitcher', () => { beforeEach(() => { + mockClearJwtCookie.mockClear(); mockClearLoginCookie.mockClear(); mockClearSession.mockClear(); mockNavigate.mockClear(); @@ -102,6 +106,7 @@ describe('AccountSwitcher', () => { await user.click(screen.getByRole('button', { name: /Log out/i })); expect(mockClearSession).toHaveBeenCalled(); + expect(mockClearJwtCookie).toHaveBeenCalled(); expect(mockClearLoginCookie).toHaveBeenCalled(); expect(mockRemoveLogin).not.toHaveBeenCalled(); }); diff --git a/src/components/auth/AccountSwitcher.tsx b/src/components/auth/AccountSwitcher.tsx index e070a870..fcbc4a0a 100644 --- a/src/components/auth/AccountSwitcher.tsx +++ b/src/components/auth/AccountSwitcher.tsx @@ -17,7 +17,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx' import { useNostrLogin } from '@nostrify/react/login'; import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts'; import { useDivineSession } from '@/hooks/useDivineSession'; -import { clearLoginCookie } from '@/lib/crossSubdomainAuth'; +import { clearLoginCookie, clearJwtCookie } from '@/lib/crossSubdomainAuth'; import { genUserName } from '@/lib/genUserName'; import { getSafeProfileImage } from '@/lib/imageUtils'; import { getActiveLocalNsecLogin } from '@/lib/localNsecAccount'; @@ -51,6 +51,7 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) { const handleLogout = () => { if (isJwtCurrentUser) { clearSession(); + clearJwtCookie(); clearLoginCookie(); return; } diff --git a/src/hooks/useDivineSession.ts b/src/hooks/useDivineSession.ts index 501f65c0..4ef509fa 100644 --- a/src/hooks/useDivineSession.ts +++ b/src/hooks/useDivineSession.ts @@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { getJWTExpiration } from '@/lib/jwtDecode'; -import { setJwtCookie, clearJwtCookie } from '@/lib/crossSubdomainAuth'; +import { setJwtCookie } from '@/lib/crossSubdomainAuth'; // Legacy key names stay in place so existing hosted-login sessions survive this rename. const TOKEN_KEY = 'keycast_jwt_token'; @@ -207,7 +207,14 @@ export function useDivineSession() { }, [bunkerUrl]); /** - * Clear session (logout) + * Clear *this origin's* session keys. + * + * Intentionally does NOT clear the cross-subdomain `divine_jwt` cookie: + * automatic expiry on one subdomain shouldn't log the user out everywhere. + * `hydrateLoginFromCookie` already refuses to hydrate from an expired + * cookie payload, so leaving the cookie behind is safe. + * + * Explicit logout (AccountSwitcher) calls `clearJwtCookie()` directly. */ const clearSession = useCallback(() => { setToken(null); @@ -216,7 +223,6 @@ export function useDivineSession() { setEmail(null); setRememberMe(false); setBunkerUrl(null); - clearJwtCookie(); }, [setToken, setExpiration, setSessionStart, setEmail, setRememberMe, setBunkerUrl]); /** diff --git a/src/lib/crossSubdomainAuth.test.ts b/src/lib/crossSubdomainAuth.test.ts index 7fe0d2ce..9879bf80 100644 --- a/src/lib/crossSubdomainAuth.test.ts +++ b/src/lib/crossSubdomainAuth.test.ts @@ -32,6 +32,8 @@ function setHostname(hostname: string) { }); } +let fetchMock: ReturnType; + beforeEach(() => { cookieJar = ''; Object.defineProperty(document, 'cookie', { @@ -55,6 +57,9 @@ beforeEach(() => { localStorageMock.clear(); setHostname('divine.video'); + + fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + Object.defineProperty(global, 'fetch', { value: fetchMock, writable: true, configurable: true }); }); afterEach(() => { @@ -140,6 +145,74 @@ describe('clearLoginCookie', () => { }); }); +describe('server-side persist fallback', () => { + it('setLoginCookie also POSTs to /api/auth/persist-cookie', () => { + setHostname('divine.video'); + setLoginCookie({ type: 'extension', pubkey: 'abc123' }); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/auth/persist-cookie', + expect.objectContaining({ + method: 'POST', + credentials: 'same-origin', + }), + ); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.name).toBe('nostr_login'); + expect(typeof body.value).toBe('string'); + expect(body.value.length).toBeGreaterThan(0); + expect(body.maxAge).toBe(60 * 60 * 24 * 365); + }); + + it('setJwtCookie also POSTs to /api/auth/persist-cookie with JWT max-age', () => { + setHostname('alice.divine.video'); + setJwtCookie({ + token: 'eyJ.test', + expiration: Date.now() + 86400000, + sessionStart: Date.now(), + rememberMe: false, + }); + + const persistCall = fetchMock.mock.calls.find((c) => c[0] === '/api/auth/persist-cookie'); + expect(persistCall).toBeDefined(); + const body = JSON.parse(persistCall![1].body); + expect(body.name).toBe('divine_jwt'); + expect(body.maxAge).toBe(60 * 60 * 24 * 7); + }); + + it('clearLoginCookie also DELETEs server-side', () => { + setHostname('divine.video'); + clearLoginCookie(); + + const deleteCall = fetchMock.mock.calls.find((c) => c[1]?.method === 'DELETE'); + expect(deleteCall).toBeDefined(); + expect(deleteCall![0]).toBe('/api/auth/persist-cookie'); + expect(JSON.parse(deleteCall![1].body)).toEqual({ name: 'nostr_login' }); + }); + + it('clearJwtCookie also DELETEs server-side', () => { + setHostname('divine.video'); + clearJwtCookie(); + + const deleteCall = fetchMock.mock.calls.find((c) => c[1]?.method === 'DELETE'); + expect(deleteCall).toBeDefined(); + expect(JSON.parse(deleteCall![1].body)).toEqual({ name: 'divine_jwt' }); + }); + + it('does not POST on localhost (no cookie domain)', () => { + setHostname('localhost'); + setLoginCookie({ type: 'extension', pubkey: 'abc123' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('a fetch rejection does not break setLoginCookie', () => { + setHostname('divine.video'); + fetchMock.mockRejectedValueOnce(new Error('network down')); + expect(() => setLoginCookie({ type: 'extension', pubkey: 'abc123' })).not.toThrow(); + expect(cookieJar).toContain('nostr_login='); + }); +}); + describe('hydrateLoginFromCookie', () => { const STORAGE_KEY = 'nostr:login'; const mockUUID = '00000000-0000-0000-0000-000000000000'; diff --git a/src/lib/crossSubdomainAuth.ts b/src/lib/crossSubdomainAuth.ts index be8147c6..881bd4ba 100644 --- a/src/lib/crossSubdomainAuth.ts +++ b/src/lib/crossSubdomainAuth.ts @@ -5,10 +5,44 @@ * have separate login state. We mirror minimal login info to a cookie * with domain=.divine.video (or .dvines.org for staging) so any * subdomain can hydrate its localStorage. + * + * Cookies are written two ways: + * 1. Synchronously via document.cookie — works in most browsers/contexts. + * 2. Asynchronously via POST /api/auth/persist-cookie — server emits + * Set-Cookie. This is the reliable path: some browsers (Brave, Firefox + * ETP-Strict, Safari ITP edge cases) silently drop document.cookie + * writes that would otherwise look fine. */ const COOKIE_NAME = 'nostr_login'; const JWT_COOKIE_NAME = 'divine_jwt'; +const PERSIST_ENDPOINT = '/api/auth/persist-cookie'; +const NOSTR_LOGIN_MAX_AGE_SECS = 60 * 60 * 24 * 365; // 1 year +const JWT_MAX_AGE_SECS = 60 * 60 * 24 * 7; // 1 week (matches session max) + +function persistCookieOnServer(name: string, value: string, maxAge: number): void { + if (typeof fetch !== 'function') return; + // Fire-and-forget. Errors (offline, edge worker hiccup) are non-fatal — + // document.cookie above is the primary path, this is the reliable backup. + void fetch(PERSIST_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, value, maxAge }), + credentials: 'same-origin', + keepalive: true, + }).catch(() => undefined); +} + +function clearCookieOnServer(name: string): void { + if (typeof fetch !== 'function') return; + void fetch(PERSIST_ENDPOINT, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + credentials: 'same-origin', + keepalive: true, + }).catch(() => undefined); +} interface LoginCookieData { type: 'extension' | 'bunker' | 'nsec'; @@ -37,13 +71,19 @@ export function setLoginCookie(loginData: LoginCookieData): void { const domain = getCookieDomain(); if (!domain) return; // cookies with domain= don't work on localhost + let value: string; + try { + value = btoa(JSON.stringify(loginData)); + } catch { + return; + } + try { - const value = btoa(JSON.stringify(loginData)); const parts = [ `${COOKIE_NAME}=${value}`, `domain=${domain}`, `path=/`, - `max-age=${60 * 60 * 24 * 365}`, // 1 year + `max-age=${NOSTR_LOGIN_MAX_AGE_SECS}`, `SameSite=Lax`, `Secure`, ]; @@ -51,6 +91,8 @@ export function setLoginCookie(loginData: LoginCookieData): void { } catch { // silently fail - cookie is best-effort } + + persistCookieOnServer(COOKIE_NAME, value, NOSTR_LOGIN_MAX_AGE_SECS); } export function clearLoginCookie(): void { @@ -61,6 +103,8 @@ export function clearLoginCookie(): void { document.cookie = `${COOKIE_NAME}=; domain=${domain}; path=/; max-age=0; Secure`; // Also clear without domain (in case one was set without it) document.cookie = `${COOKIE_NAME}=; path=/; max-age=0; Secure`; + + clearCookieOnServer(COOKIE_NAME); } export function getLoginCookie(): LoginCookieData | null { @@ -79,13 +123,19 @@ export function setJwtCookie(data: JwtCookieData): void { const domain = getCookieDomain(); if (!domain) return; + let value: string; + try { + value = btoa(JSON.stringify(data)); + } catch { + return; + } + try { - const value = btoa(JSON.stringify(data)); const parts = [ `${JWT_COOKIE_NAME}=${value}`, `domain=${domain}`, `path=/`, - `max-age=${60 * 60 * 24 * 7}`, // 1 week (matches session max) + `max-age=${JWT_MAX_AGE_SECS}`, `SameSite=Lax`, `Secure`, ]; @@ -93,6 +143,8 @@ export function setJwtCookie(data: JwtCookieData): void { } catch { // silently fail - cookie is best-effort } + + persistCookieOnServer(JWT_COOKIE_NAME, value, JWT_MAX_AGE_SECS); } export function getJwtCookie(): JwtCookieData | null { @@ -111,6 +163,8 @@ export function clearJwtCookie(): void { document.cookie = `${JWT_COOKIE_NAME}=; domain=${domain}; path=/; max-age=0; Secure`; document.cookie = `${JWT_COOKIE_NAME}=; path=/; max-age=0; Secure`; + + clearCookieOnServer(JWT_COOKIE_NAME); } /** @@ -120,6 +174,7 @@ export function clearJwtCookie(): void { */ export function hydrateLoginFromCookie(): void { const STORAGE_KEY = 'nostr:login'; + const log = (...args: unknown[]) => console.info('[crossSubdomainAuth]', ...args); // --- JWT session hydration --- // JWT keys used by useDivineSession (must match those constants) @@ -138,6 +193,7 @@ export function hydrateLoginFromCookie(): void { const expiration = localStorage.getItem(JWT_EXPIRATION_KEY); const sessionStart = localStorage.getItem(JWT_SESSION_START_KEY); if (expiration && sessionStart) { + log('JWT: localStorage present, syncing cookie'); setJwtCookie({ token: existingJwt, expiration: JSON.parse(expiration), @@ -150,7 +206,18 @@ export function hydrateLoginFromCookie(): void { } else { // No JWT on this origin — check cookie const jwtCookie = getJwtCookie(); - if (jwtCookie && jwtCookie.token && jwtCookie.expiration > Date.now()) { + if (!jwtCookie) { + log('JWT: no localStorage, no cookie'); + } else if (!jwtCookie.token) { + log('JWT: cookie present but missing token field'); + } else if (jwtCookie.expiration <= Date.now()) { + log('JWT: cookie present but expired', { + expiredSecAgo: Math.round((Date.now() - jwtCookie.expiration) / 1000), + }); + } else { + log('JWT: hydrating localStorage from cookie', { + expiresInSec: Math.round((jwtCookie.expiration - Date.now()) / 1000), + }); localStorage.setItem(JWT_TOKEN_KEY, JSON.stringify(jwtCookie.token)); localStorage.setItem(JWT_EXPIRATION_KEY, JSON.stringify(jwtCookie.expiration)); localStorage.setItem(JWT_SESSION_START_KEY, JSON.stringify(jwtCookie.sessionStart)); @@ -173,6 +240,7 @@ export function hydrateLoginFromCookie(): void { const logins = JSON.parse(stored); if (Array.isArray(logins) && logins.length > 0) { const first = logins[0]; + log('nostr_login: localStorage present, syncing cookie', { type: first.type }); // Keep cookie in sync with current login setLoginCookie({ type: first.type, @@ -187,11 +255,15 @@ export function hydrateLoginFromCookie(): void { } const cookie = getLoginCookie(); - if (!cookie) return; + if (!cookie) { + log('nostr_login: no localStorage, no cookie'); + return; + } // For extension logins, we can fully restore - the extension (window.nostr) // is available on all origins if (cookie.type === 'extension') { + log('nostr_login: hydrating extension login from cookie'); const loginState = [{ id: crypto.randomUUID(), type: 'extension' as const, @@ -203,6 +275,7 @@ export function hydrateLoginFromCookie(): void { // For bunker logins, restore with the bunker URI so it can reconnect if (cookie.type === 'bunker' && cookie.bunkerUri) { + log('nostr_login: hydrating bunker login from cookie'); const loginState = [{ id: crypto.randomUUID(), type: 'bunker' as const, @@ -213,6 +286,10 @@ export function hydrateLoginFromCookie(): void { return; } + if (cookie.type === 'nsec') { + log('nostr_login: nsec cookie present but private key not in cookie — re-login required on this subdomain'); + } + // For nsec logins, we can't restore the private key from the cookie // (intentionally not stored for security). User will need to re-login // on this subdomain. We don't clear the cookie though, so other