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
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
13 changes: 3 additions & 10 deletions apps/api/src/controllers/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -148,18 +144,15 @@ 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},
});
}

@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')
Expand Down
4 changes: 1 addition & 3 deletions apps/api/src/controllers/Oauth/Github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
4 changes: 1 addition & 3 deletions apps/api/src/controllers/Oauth/Google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
78 changes: 78 additions & 0 deletions apps/api/src/controllers/__tests__/Auth.cookie.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('../../app/constants.js')>()),
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=');
});
});
63 changes: 51 additions & 12 deletions apps/api/src/services/UserService.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand All @@ -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('.')}`;
}

Expand All @@ -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';

Expand Down Expand Up @@ -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()));
}
}
75 changes: 75 additions & 0 deletions apps/api/src/services/__tests__/UserService.cookie.test.ts
Original file line number Diff line number Diff line change
@@ -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=');
});
});
8 changes: 4 additions & 4 deletions apps/landing/src/pages/privacy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,8 @@ export default function PrivacyPolicy() {
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Purpose: Store JWT token for dashboard login</li>
<li>Duration: 7 days</li>
<li>Type: First-party, httpOnly, secure (HTTPS only)</li>
<li>Scope: *.useplunk.com</li>
<li>Type: Essential, httpOnly, secure (HTTPS only)</li>
<li>Scope: API host only</li>
<li>Can be deleted: Yes (logout clears cookie)</li>
</ul>

Expand Down Expand Up @@ -469,7 +469,7 @@ export default function PrivacyPolicy() {
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Password hashing: Industry-standard secure hashing</li>
<li>Cookies: httpOnly, secure, SameSite=none (HTTPS)</li>
<li>Cookies: httpOnly, secure, SameSite=Lax for same-site deployments</li>
<li>Database: TLS/SSL encrypted connections</li>
<li>API: HTTPS-only (TLS 1.2+)</li>
<li>JWT tokens: 7-day expiration, httpOnly cookies</li>
Expand Down Expand Up @@ -883,7 +883,7 @@ export default function PrivacyPolicy() {
<strong className="font-semibold text-neutral-900">Duration:</strong> 7 days
</li>
<li>
<strong className="font-semibold text-neutral-900">Domain:</strong> *.useplunk.com
<strong className="font-semibold text-neutral-900">Domain:</strong> API host only
</li>
<li>
<strong className="font-semibold text-neutral-900">Data stored:</strong> Encrypted JWT with user
Expand Down
19 changes: 19 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8271,6 +8271,7 @@ __metadata:
sanitize-html: "npm:^2.17.5"
signale: "npm:^1.4.0"
stripe: "npm:^20.0.0"
tldts: "npm:^7.4.11"
tsx: "npm:^4.20.6"
zod: "npm:^3.23.8"
languageName: unknown
Expand Down Expand Up @@ -19019,6 +19020,24 @@ __metadata:
languageName: node
linkType: hard

"tldts-core@npm:^7.4.11":
version: 7.4.11
resolution: "tldts-core@npm:7.4.11"
checksum: 10c0/df144de6f97458a88146897cdbfb3059a3ddc124f6ceb20c9bae71a6c7c41b553ea83b6791726b2387b10babf1d714f8c95b672e9867bfe146f9788f4811749e
languageName: node
linkType: hard

"tldts@npm:^7.4.11":
version: 7.4.11
resolution: "tldts@npm:7.4.11"
dependencies:
tldts-core: "npm:^7.4.11"
bin:
tldts: bin/cli.js
checksum: 10c0/c1cf432c4cae47bb6473fc7cbd8b7df0fb98c18b7bcd3fc138ea45a71854705c681a4c998e10b82cc014404693b0b2cd5c23c4c76d6978390a46c604ca0e5398
languageName: node
linkType: hard

"to-regex-range@npm:^5.0.1":
version: 5.0.1
resolution: "to-regex-range@npm:5.0.1"
Expand Down