diff --git a/apps/api/package.json b/apps/api/package.json index e0c66daed..25f9e2c6a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -42,6 +42,7 @@ "sanitize-html": "^2.17.5", "signale": "^1.4.0", "stripe": "^20.0.0", + "tldts": "^7.4.11", "zod": "^3.23.8" }, "devDependencies": { diff --git a/apps/api/src/controllers/Auth.ts b/apps/api/src/controllers/Auth.ts index c6b7b6683..85166c87b 100644 --- a/apps/api/src/controllers/Auth.ts +++ b/apps/api/src/controllers/Auth.ts @@ -55,11 +55,7 @@ export class Auth { await redis.set(Keys.User.id(user.id), JSON.stringify(user), 'EX', REDIS_ONE_MINUTE * 60); const token = jwt.sign(user.id); - const cookie = UserService.cookieOptions(); - - return res - .cookie(UserService.COOKIE_NAME, token, cookie) - .json({success: true, data: {id: user.id, email: user.email}}); + return UserService.setAuthCookie(res, token).json({success: true, data: {id: user.id, email: user.email}}); } @Post('signup') @@ -148,9 +144,7 @@ export class Auth { } const token = jwt.sign(created_user.id); - const cookie = UserService.cookieOptions(); - - return res.cookie(UserService.COOKIE_NAME, token, cookie).json({ + return UserService.setAuthCookie(res, token).json({ success: true, data: {id: created_user.id, email: created_user.email}, }); @@ -158,8 +152,7 @@ export class Auth { @Get('logout') public logout(req: Request, res: Response) { - res.cookie(UserService.COOKIE_NAME, '', UserService.cookieOptions(new Date())); - return res.json(true); + return UserService.clearAuthCookie(res).json(true); } @Get('oauth-config') diff --git a/apps/api/src/controllers/Oauth/Github.ts b/apps/api/src/controllers/Oauth/Github.ts index 74f440688..f15b84206 100644 --- a/apps/api/src/controllers/Oauth/Github.ts +++ b/apps/api/src/controllers/Oauth/Github.ts @@ -108,8 +108,6 @@ export class Github { } const token = jwt.sign(user.id); - const cookie = UserService.cookieOptions(); - - res.cookie(UserService.COOKIE_NAME, token, cookie).redirect(DASHBOARD_URI); + UserService.setAuthCookie(res, token).redirect(DASHBOARD_URI); } } diff --git a/apps/api/src/controllers/Oauth/Google.ts b/apps/api/src/controllers/Oauth/Google.ts index 7c070f66f..e84cef01c 100644 --- a/apps/api/src/controllers/Oauth/Google.ts +++ b/apps/api/src/controllers/Oauth/Google.ts @@ -98,8 +98,6 @@ export class Google { } const token = jwt.sign(user.id); - const cookie = UserService.cookieOptions(); - - res.cookie(UserService.COOKIE_NAME, token, cookie).redirect(DASHBOARD_URI); + UserService.setAuthCookie(res, token).redirect(DASHBOARD_URI); } } diff --git a/apps/api/src/controllers/__tests__/Auth.cookie.test.ts b/apps/api/src/controllers/__tests__/Auth.cookie.test.ts new file mode 100644 index 000000000..900a7a42a --- /dev/null +++ b/apps/api/src/controllers/__tests__/Auth.cookie.test.ts @@ -0,0 +1,78 @@ +import type {NextFunction, Request, Response} from 'express'; +import express from 'express'; +import request from 'supertest'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import {factories} from '../../../../../test/helpers/index.js'; +import {NtfyService} from '../../services/NtfyService.js'; +import {Auth} from '../Auth.js'; + +vi.mock('../../app/constants.js', async importOriginal => ({ + ...(await importOriginal()), + DISABLE_SIGNUPS: false, + PLUNK_ENABLED: false, + VERIFY_EMAIL_ON_SIGNUP: false, +})); + +function createAuthApp() { + const app = express(); + const auth = new Auth(); + + app.use(express.json()); + app.post('/auth/login', (req, res, next) => void auth.login(req, res, next)); + app.post('/auth/signup', (req, res, next) => void auth.signup(req, res, next)); + app.get('/auth/logout', (req, res) => auth.logout(req, res)); + app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => { + res.status(500).json({success: false, error: error.message}); + }); + + return app; +} + +function currentCookie(response: request.Response): string { + const header = response.headers['set-cookie']; + if (!header) return ''; + const cookies = Array.isArray(header) ? header : [header]; + return cookies.at(-1) ?? ''; +} + +describe('authentication response cookies', () => { + beforeEach(() => { + vi.spyOn(NtfyService, 'notifyUserSignup').mockResolvedValue(undefined); + }); + + it('sets a host-only cookie on signup', async () => { + const response = await request(createAuthApp()).post('/auth/signup').send({ + email: 'cookie-signup@example.com', + password: 'password123', + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(currentCookie(response)).toContain('next_token='); + expect(currentCookie(response)).not.toContain('Domain='); + }); + + it('sets a host-only cookie on login', async () => { + await factories.createUser({email: 'cookie-login@example.com', password: 'password123'}); + + const response = await request(createAuthApp()).post('/auth/login').send({ + email: 'cookie-login@example.com', + password: 'password123', + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(currentCookie(response)).toContain('next_token='); + expect(currentCookie(response)).not.toContain('Domain='); + }); + + it('expires a host-only cookie on logout', async () => { + const response = await request(createAuthApp()).get('/auth/logout'); + + expect(response.status).toBe(200); + expect(currentCookie(response)).toContain('next_token=;'); + expect(currentCookie(response)).toContain('Expires='); + expect(currentCookie(response)).not.toContain('Domain='); + }); +}); diff --git a/apps/api/src/services/UserService.ts b/apps/api/src/services/UserService.ts index 130083978..77c1d5fe4 100644 --- a/apps/api/src/services/UserService.ts +++ b/apps/api/src/services/UserService.ts @@ -1,18 +1,18 @@ import dayjs from 'dayjs'; +import type {Response} from 'express'; +import {getDomain} from 'tldts'; -import {API_URI, NODE_ENV} from '../app/constants.js'; +import {API_URI, DASHBOARD_URI, NODE_ENV} from '../app/constants.js'; import {prisma} from '../database/prisma.js'; import {wrapRedis} from '../database/redis.js'; import {Keys} from './keys.js'; /** - * Extract base domain from URL for cookie sharing across subdomains - * e.g., "http://api.example.com" -> ".example.com" - * e.g., "http://api.localhost" -> ".localhost" - * e.g., "http://app.plunk.local" -> ".plunk.local" + * Reproduce the domain scope used before host-only cookies. This is used only + * to expire an existing legacy cookie during login/logout migration. */ -function getCookieDomain(): string | undefined { +function getLegacyCookieDomain(): string | undefined { if (NODE_ENV === 'development') { return undefined; } @@ -26,18 +26,15 @@ function getCookieDomain(): string | undefined { return undefined; } - // Extract base domain (last two parts for most domains, or .localhost) + // This intentionally matches the old last-two-label algorithm exactly. const parts = hostname.split('.'); if (parts.length >= 2) { - // For *.localhost, use .localhost (reserved TLD) if (hostname.endsWith('.localhost')) { return '.localhost'; } - // For *.local (mDNS TLD), use the actual base domain if (hostname.endsWith('.local')) { return `.${parts.slice(-2).join('.')}`; } - // For other domains, use the last two parts (e.g., .example.com) return `.${parts.slice(-2).join('.')}`; } @@ -47,6 +44,28 @@ function getCookieDomain(): string | undefined { } } +function getSchemefulSite(uri: string): string | undefined { + try { + const url = new URL(uri); + const registrableDomain = getDomain(url.hostname, {allowPrivateDomains: true}); + + return `${url.protocol}//${registrableDomain ?? url.hostname}`; + } catch { + return undefined; + } +} + +export function getCookieSameSite(apiUri: string, dashboardUri: string, secure: boolean): 'lax' | 'none' { + if (!secure) { + return 'lax'; + } + + const apiSite = getSchemefulSite(apiUri); + const dashboardSite = getSchemefulSite(dashboardUri); + + return apiSite !== undefined && apiSite === dashboardSite ? 'lax' : 'none'; +} + export class UserService { public static readonly COOKIE_NAME = 'next_token'; @@ -95,9 +114,29 @@ export class UserService { httpOnly: true, expires: expires ?? dayjs().add(7, 'days').toDate(), secure: isHttps, - sameSite: isHttps ? 'none' : 'lax', + sameSite: getCookieSameSite(API_URI, DASHBOARD_URI, isHttps), path: '/', - domain: getCookieDomain(), } as const; } + + private static clearLegacyCookie(res: Response) { + const domain = getLegacyCookieDomain(); + + if (domain) { + res.cookie(UserService.COOKIE_NAME, '', { + ...UserService.cookieOptions(new Date()), + domain, + }); + } + } + + public static setAuthCookie(res: Response, token: string) { + UserService.clearLegacyCookie(res); + return res.cookie(UserService.COOKIE_NAME, token, UserService.cookieOptions()); + } + + public static clearAuthCookie(res: Response) { + UserService.clearLegacyCookie(res); + return res.cookie(UserService.COOKIE_NAME, '', UserService.cookieOptions(new Date())); + } } diff --git a/apps/api/src/services/__tests__/UserService.cookie.test.ts b/apps/api/src/services/__tests__/UserService.cookie.test.ts new file mode 100644 index 000000000..b844a2631 --- /dev/null +++ b/apps/api/src/services/__tests__/UserService.cookie.test.ts @@ -0,0 +1,75 @@ +import express from 'express'; +import request from 'supertest'; +import {describe, expect, it, vi} from 'vitest'; + +vi.mock('../../app/constants.js', () => ({ + API_URI: 'https://api.example.com', + DASHBOARD_URI: 'https://app.example.com', + NODE_ENV: 'production', +})); + +vi.mock('../../database/prisma.js', () => ({prisma: {}})); +vi.mock('../../database/redis.js', () => ({wrapRedis: vi.fn()})); + +import {getCookieSameSite, UserService} from '../UserService.js'; + +function createCookieApp(action: 'clear' | 'set') { + const app = express(); + + app.get('/', (_req, res) => { + if (action === 'set') { + UserService.setAuthCookie(res, 'signed-token').json(true); + } else { + UserService.clearAuthCookie(res).json(true); + } + }); + + return app; +} + +function setCookieHeaders(response: request.Response): string[] { + const header = response.headers['set-cookie']; + if (!header) return []; + return Array.isArray(header) ? header : [header]; +} + +describe('authentication cookie scope', () => { + it('uses Public Suffix List semantics for same-site decisions', () => { + expect(getCookieSameSite('https://api.example.co.uk', 'https://app.example.co.uk', true)).toBe('lax'); + expect( + getCookieSameSite( + 'https://plunk-api-production-7c42.up.railway.app', + 'https://plunk-dashboard-production-e2e5.up.railway.app', + true, + ), + ).toBe('none'); + expect(getCookieSameSite('http://api.example.com', 'http://app.example.com', false)).toBe('lax'); + }); + + it('expires the legacy domain cookie before setting a host-only cookie', async () => { + const response = await request(createCookieApp('set')).get('/'); + const cookies = setCookieHeaders(response); + + expect(cookies).toHaveLength(2); + expect(cookies[0]).toContain('next_token=;'); + expect(cookies[0]).toContain('Domain=.example.com'); + expect(cookies[0]).toContain('Expires='); + expect(cookies[1]).toContain('next_token=signed-token;'); + expect(cookies[1]).not.toContain('Domain='); + expect(cookies[1]).toContain('Path=/'); + expect(cookies[1]).toContain('HttpOnly'); + expect(cookies[1]).toContain('Secure'); + expect(cookies[1]).toContain('SameSite=Lax'); + }); + + it('expires both the legacy domain cookie and the current host-only cookie on logout', async () => { + const response = await request(createCookieApp('clear')).get('/'); + const cookies = setCookieHeaders(response); + + expect(cookies).toHaveLength(2); + expect(cookies[0]).toContain('Domain=.example.com'); + expect(cookies[0]).toContain('Expires='); + expect(cookies[1]).not.toContain('Domain='); + expect(cookies[1]).toContain('Expires='); + }); +}); diff --git a/apps/landing/src/pages/privacy.tsx b/apps/landing/src/pages/privacy.tsx index 4e4a393cc..38cacc78c 100644 --- a/apps/landing/src/pages/privacy.tsx +++ b/apps/landing/src/pages/privacy.tsx @@ -304,8 +304,8 @@ export default function PrivacyPolicy() { @@ -469,7 +469,7 @@ export default function PrivacyPolicy() {