From ac8ccf53d792e9bf66b96a1cdfcc61b87e26b715 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Sun, 11 Oct 2026 05:46:25 +0000 Subject: [PATCH] feat(web): keep users signed in for 7 days instead of 24 hours The middleware capped the Supabase auth cookies at 24 hours, so a browser that went a day without a server-side session refresh was signed out. Raise the window to 7 days; it still slides with each refresh. Also pass supabase-ssr's cookie deletions (maxAge 0) through unchanged. The override previously turned them into 24-hour empty cookies, which a 7-day window would have stretched to a week. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- .../web/src/lib/auth/session-cookie.spec.ts | 23 +++++++++++++ packages/web/src/lib/auth/session-cookie.ts | 32 +++++++++++++++++++ packages/web/src/middleware.ts | 10 +++--- 3 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 packages/web/src/lib/auth/session-cookie.spec.ts create mode 100644 packages/web/src/lib/auth/session-cookie.ts diff --git a/packages/web/src/lib/auth/session-cookie.spec.ts b/packages/web/src/lib/auth/session-cookie.spec.ts new file mode 100644 index 000000000..ef45871c9 --- /dev/null +++ b/packages/web/src/lib/auth/session-cookie.spec.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { SESSION_MAX_AGE_SECONDS, sessionCookieMaxAge } from './session-cookie'; + +describe('SESSION_MAX_AGE_SECONDS', () => { + it('keeps a user signed in for 7 days without a session refresh', () => { + expect(SESSION_MAX_AGE_SECONDS).toBe(7 * 24 * 60 * 60); + }); +}); + +describe('sessionCookieMaxAge', () => { + it('applies the session window to a cookie supabase-ssr is setting', () => { + // supabase-ssr 0.6 sets session cookies with its own 400-day default. + expect(sessionCookieMaxAge(400 * 24 * 60 * 60)).toBe(SESSION_MAX_AGE_SECONDS); + }); + + it('applies the session window when no max-age was requested', () => { + expect(sessionCookieMaxAge(undefined)).toBe(SESSION_MAX_AGE_SECONDS); + }); + + it('passes a deletion (maxAge 0) through so sign-out clears the cookie', () => { + expect(sessionCookieMaxAge(0)).toBe(0); + }); +}); diff --git a/packages/web/src/lib/auth/session-cookie.ts b/packages/web/src/lib/auth/session-cookie.ts new file mode 100644 index 000000000..57a76bb26 --- /dev/null +++ b/packages/web/src/lib/auth/session-cookie.ts @@ -0,0 +1,32 @@ +/** + * Lifetime of the Supabase auth cookies the middleware writes. + * + * Dependency-free on purpose: the edge middleware imports it, and the edge + * bundle must not pull in Node-only modules. + */ + +/** + * 7 days. This is how long a browser can go without a server-side session + * refresh before the auth cookies expire and the user is signed out. Every + * refresh re-issues the cookies with a fresh max-age, so the window slides with + * use: someone who opens the dashboard at least once a week stays signed in. + * + * It must stay longer than the access token's `jwt_expiry` (1 h on the hosted + * project, 24 h in `supabase/config.toml`), so the refresh token is still in + * the cookie when the access token runs out. + */ +export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; + +/** + * The max-age to write for one auth cookie, given the max-age supabase-ssr + * asked for. + * + * supabase-ssr deletes a cookie by writing it empty with `maxAge: 0` (on sign + * out, and when a session cookie is re-chunked). That deletion has to pass + * through unchanged; overriding it would leave empty auth cookies in the + * browser for the whole session window. + */ +export function sessionCookieMaxAge(requestedMaxAge: number | undefined): number { + if (requestedMaxAge === 0) return 0; + return SESSION_MAX_AGE_SECONDS; +} diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts index 87b5a4f5f..4264f74ad 100644 --- a/packages/web/src/middleware.ts +++ b/packages/web/src/middleware.ts @@ -6,10 +6,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import { safeNextPath, boundedReturnTo } from '@/lib/auth-redirect'; import { supabaseAnonKey, supabaseUrl } from '@/lib/supabase/config'; import { ensureFlagAnonIdCookie } from '@/lib/feature-flags/anon-id'; - -/** 24 hours — matches the Supabase project jwt_expiry so the cookie - * outlives the access token and the refresh token can be used. */ -const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24; +import { sessionCookieMaxAge } from '@/lib/auth/session-cookie'; export async function middleware(request: NextRequest) { // Short-circuit OPTIONS preflights before hitting any auth logic. @@ -90,8 +87,9 @@ export async function middleware(request: NextRequest) { response.cookies.set(name, value, { ...options, // Persist the session cookie across browser restarts so the - // refresh token survives and the user stays logged in for the day. - maxAge: SESSION_MAX_AGE_SECONDS, + // refresh token survives and the user stays signed in for the + // session window (7 days, sliding with each refresh). + maxAge: sessionCookieMaxAge(options?.maxAge), }), ); },