Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions compute-js/src/authPersistCookie.js
Original file line number Diff line number Diff line change
@@ -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',
},
});
}
148 changes: 148 additions & 0 deletions compute-js/src/authPersistCookie.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
8 changes: 8 additions & 0 deletions compute-js/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/components/auth/AccountSwitcher.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const {
mockClearJwtCookie,
mockClearLoginCookie,
mockClearSession,
mockNavigate,
mockRemoveLogin,
mockSetLogin,
mockUseLoggedInAccounts,
} = vi.hoisted(() => ({
mockClearJwtCookie: vi.fn(),
mockClearLoginCookie: vi.fn(),
mockClearSession: vi.fn(),
mockNavigate: vi.fn(),
Expand Down Expand Up @@ -40,6 +42,7 @@ vi.mock('@/hooks/useDivineSession', () => ({

vi.mock('@/lib/crossSubdomainAuth', () => ({
clearLoginCookie: mockClearLoginCookie,
clearJwtCookie: mockClearJwtCookie,
}));

vi.mock('@/components/ui/dropdown-menu.tsx', () => ({
Expand Down Expand Up @@ -77,6 +80,7 @@ import { AccountSwitcher } from './AccountSwitcher';

describe('AccountSwitcher', () => {
beforeEach(() => {
mockClearJwtCookie.mockClear();
mockClearLoginCookie.mockClear();
mockClearSession.mockClear();
mockNavigate.mockClear();
Expand All @@ -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();
});
Expand Down
3 changes: 2 additions & 1 deletion src/components/auth/AccountSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -51,6 +51,7 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
const handleLogout = () => {
if (isJwtCurrentUser) {
clearSession();
clearJwtCookie();
clearLoginCookie();
return;
}
Expand Down
12 changes: 9 additions & 3 deletions src/hooks/useDivineSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -216,7 +223,6 @@ export function useDivineSession() {
setEmail(null);
setRememberMe(false);
setBunkerUrl(null);
clearJwtCookie();
}, [setToken, setExpiration, setSessionStart, setEmail, setRememberMe, setBunkerUrl]);

/**
Expand Down
Loading
Loading