Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/guide/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 26 additions & 6 deletions packages/void/src/runtime/better-auth-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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,
Expand Down
98 changes: 98 additions & 0 deletions packages/void/test/integration/auth-isr-cache-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<string, string>, { 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);
});
});
37 changes: 37 additions & 0 deletions packages/void/test/unit/auth-middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading