diff --git a/docs/guide/auth.md b/docs/guide/auth.md index 40617b404..2e16fa3af 100644 --- a/docs/guide/auth.md +++ b/docs/guide/auth.md @@ -127,6 +127,8 @@ Auth sessions use the same database as your app. Void creates `BETTER_AUTH_SECRE Local development needs no secret setup. To preview a production build locally, provide `BETTER_AUTH_SECRET`. +Responses to signed-in requests get `Cache-Control: no-store`, so neither the browser cache nor a shared cache keeps a user's data. A route keeps its own policy when it sets `no-store`, or `private` together with `no-cache`. To answer repeat requests with `304`, use `private, no-cache` with Hono's [`etag()`](https://hono.dev/docs/middleware/builtin/etag) middleware. If you set the `ETag` yourself, make it differ per user, so the `304` doesn't reuse another account's page. The browser cache keeps a copy of a `private, no-cache` page, so the Back button can show it again after the user signs out. Keep the default `no-store` for pages that must not stay in the browser cache. + `POST`, `PUT`, `PATCH` and `DELETE` requests and WebSocket connections from another origin don't use the signed-in session unless that origin is in `trustedOrigins`. If a separate frontend calls your app, add its origin there (see [Customization](#customization)). If your routes use their own cookies, check the request `Origin` yourself, for example with Hono's [`csrf()`](https://hono.dev/docs/middleware/builtin/csrf) middleware. Deployed apps on Cloudflare limit auth requests per client IP with Better Auth's defaults, for example 3 sign-in attempts per 10 seconds. Each Worker instance keeps its own count. Change or turn off the limits with `rateLimit` in `auth.ts`. To share one count across instances, set `rateLimit: { storage: 'database' }`, then run `void db generate` for a direct Cloudflare deploy. diff --git a/packages/void/src/runtime/better-auth-shared.ts b/packages/void/src/runtime/better-auth-shared.ts index 5b2f198e4..9ad55adc7 100644 --- a/packages/void/src/runtime/better-auth-shared.ts +++ b/packages/void/src/runtime/better-auth-shared.ts @@ -215,28 +215,48 @@ function appendAuthHeaders( return; } + // `no-store`, or `private` with `no-cache`, keeps the response out of shared + // caches and makes the browser ask before reusing it, which allows ETag + 304 + // (the ETag must differ per user). `private` alone lets the browser show the + // response to the next account that signs in. + const routeDirectives = bareCacheDirectives(c.res.headers.get('cache-control')); + const keepRoutePolicy = + routeDirectives.has('no-store') || + (routeDirectives.has('private') && routeDirectives.has('no-cache')); source.forEach((value, key) => { // getSession's response policy belongs to the session endpoint. An anonymous // lookup must not make the application's public page or asset uncacheable. - if (!includeCachePolicy && ['cache-control', 'pragma'].includes(key.toLowerCase())) { + if ( + (!includeCachePolicy || keepRoutePolicy) && + ['cache-control', 'pragma'].includes(key.toLowerCase()) + ) { return; } c.header(key, value, { append: key.toLowerCase() === 'set-cookie', }); }); + const sessionDirectives = bareCacheDirectives(source.get('cache-control')); if ( source.has('set-cookie') && - !(source.get('cache-control') ?? '') - .split(',') - .some((directive) => - ['private', 'no-store', 'no-cache'].includes(directive.trim().toLowerCase()), - ) + !keepRoutePolicy && + !['private', 'no-store', 'no-cache'].some((directive) => sessionDirectives.has(directive)) ) { c.header('Cache-Control', 'private, no-store'); } } +// Lowercased Cache-Control directive names that carry no value. Commas inside a +// quoted value do not split directives, so `no-cache="a,private"` has no `private`. +function bareCacheDirectives(policy: string | null): Set { + const directives = (policy ?? '').match(/(?:[^",]|"(?:[^"\\]|\\.)*"?)+/g) ?? []; + return new Set( + directives + .map((directive) => directive.trim().toLowerCase()) + .filter((directive) => !directive.includes('=')), + ); +} + function resolveUserConfig( userConfig: VoidAuthConfig | undefined, defaults: BetterAuthOptions, diff --git a/packages/void/test/integration/auth-isr-cache-policy.test.ts b/packages/void/test/integration/auth-isr-cache-policy.test.ts index f599ebb6e..db4de051b 100644 --- a/packages/void/test/integration/auth-isr-cache-policy.test.ts +++ b/packages/void/test/integration/auth-isr-cache-policy.test.ts @@ -1,6 +1,7 @@ import { handleIsr, type IsrCacheEnv } from '@void/isr/runtime'; import { memoryAdapter } from 'better-auth/adapters/memory'; import { Hono } from 'hono'; +import { etag } from 'hono/etag'; import { describe, expect, it } from 'vite-plus/test'; import { createVoidAuthRuntime } from '../../src/runtime/better-auth-shared.ts'; @@ -91,3 +92,100 @@ describe('real anonymous Better Auth lookup cache policy', () => { await expect(response.json()).resolves.toBeNull(); }); }); + +describe('real authenticated Better Auth session cache policy', () => { + async function signedInApp(routeHeaders: Record, { withEtag = false } = {}) { + const database = memoryAdapter({ user: [], session: [], account: [], verification: [] }); + const runtime = createVoidAuthRuntime('sqlite', async () => {}); + const app = new Hono(); + app.use(async (c, next) => runtime.runVoidAuthMiddleware(c, next, options, database)); + app.on(['GET', 'POST'], '/api/auth/*', (c) => + runtime.handleVoidAuthRequest(c, options, database), + ); + if (withEtag) { + app.use('/api/report', etag()); + } + app.get('/api/report', (c) => c.text('report', 200, routeHeaders)); + const signUp = await app.request( + 'https://app.test/api/auth/sign-up/email', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Cache user', + email: 'cache-user@example.test', + password: 'correct-horse-battery-staple', + }), + }, + env, + ); + expect(signUp.status).toBe(200); + const cookie = signUp.headers + .getSetCookie() + .map((value) => value.split(';')[0]) + .join('; '); + const session = await app.request( + 'https://app.test/api/auth/get-session', + { headers: { cookie } }, + env, + ); + await expect(session.json()).resolves.toMatchObject({ + user: { email: 'cache-user@example.test' }, + }); + return { app, cookie }; + } + + it.each(['private, no-cache', 'no-store', 'Private, No-Cache, max-age=60', 'private,\tno-cache'])( + 'keeps a no-store or private, no-cache route policy: %s', + async (policy) => { + const { app, cookie } = await signedInApp({ 'cache-control': policy, etag: 'W/"report-1"' }); + const response = await app.request( + 'https://app.test/api/report', + { headers: { cookie } }, + env, + ); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe(policy); + expect(response.headers.has('pragma')).toBe(false); + expect(response.headers.get('etag')).toBe('W/"report-1"'); + }, + ); + + it.each([ + null, + 'public, max-age=60', + 'no-cache', + 'private="set-cookie", max-age=60', + 'private', + 'private, max-age=60', + 'private, no-cache="set-cookie"', + 'no-cache="x,private,y", public, max-age=600', + 'no-cache="x,no-store,y", public, max-age=600', + 'no-cache="a\\",no-store,b", public, max-age=600', + ])('applies the session lookup no-store to a route policy of %s', async (policy) => { + const { app, cookie } = await signedInApp(policy ? { 'cache-control': policy } : {}); + const response = await app.request('https://app.test/api/report', { headers: { cookie } }, env); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + }); + + it('answers a signed-in repeat request with 304 through etag()', async () => { + const { app, cookie } = await signedInApp( + { 'cache-control': 'private, no-cache' }, + { withEtag: true }, + ); + const first = await app.request('https://app.test/api/report', { headers: { cookie } }, env); + expect(first.status).toBe(200); + const tag = first.headers.get('etag'); + expect(tag).toBeTruthy(); + const repeat = await app.request( + 'https://app.test/api/report', + { headers: { cookie, 'if-none-match': tag! } }, + env, + ); + expect(repeat.status).toBe(304); + expect(repeat.headers.get('cache-control')).toBe('private, no-cache'); + expect(repeat.headers.has('pragma')).toBe(false); + expect(repeat.headers.get('etag')).toBe(tag); + }); +}); diff --git a/packages/void/test/unit/auth-middleware.test.ts b/packages/void/test/unit/auth-middleware.test.ts index 70904fc37..50ed51642 100644 --- a/packages/void/test/unit/auth-middleware.test.ts +++ b/packages/void/test/unit/auth-middleware.test.ts @@ -173,6 +173,43 @@ describe('runVoidAuthMiddleware response headers', () => { expect(response.headers.get('cache-control')).toBe('private, no-store'); expect(response.headers.getSetCookie()).toEqual(['session=rotated; Path=/; HttpOnly']); }); + + it.each([ + ['without its own cache policy', new Headers()], + ['with a no-store policy', new Headers({ 'cache-control': 'no-store', pragma: 'no-cache' })], + ])( + 'keeps a private, no-cache route policy through a cookie rotation %s', + async (_label, authHeaders) => { + authHeaders.append('set-cookie', 'session=rotated; Path=/; HttpOnly'); + const app = createApp({ response: null, headers: authHeaders }); + app.get('/refresh', (c) => + c.text('content', 200, { 'cache-control': 'private, no-cache', etag: 'W/"v1"' }), + ); + const response = await app.request( + 'http://localhost/refresh', + { headers: { cookie: 'session=current' } }, + { BETTER_AUTH_SECRET: 'test-secret' }, + ); + expect(response.headers.get('cache-control')).toBe('private, no-cache'); + expect(response.headers.has('pragma')).toBe(false); + expect(response.headers.getSetCookie()).toEqual(['session=rotated; Path=/; HttpOnly']); + }, + ); + + it('does not let a cookie rotation keep a private route policy the browser may reuse', async () => { + const app = createApp({ + response: null, + headers: new Headers({ 'set-cookie': 'session=rotated; Path=/; HttpOnly' }), + }); + app.get('/refresh', (c) => c.text('content', 200, { 'cache-control': 'private, max-age=60' })); + const response = await app.request( + 'http://localhost/refresh', + { headers: { cookie: 'session=current' } }, + { BETTER_AUTH_SECRET: 'test-secret' }, + ); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.getSetCookie()).toEqual(['session=rotated; Path=/; HttpOnly']); + }); }); describe('cross-origin cookie sessions', () => {