diff --git a/apps/docs/users.mdx b/apps/docs/users.mdx index 0a91fa485..9fc883955 100644 --- a/apps/docs/users.mdx +++ b/apps/docs/users.mdx @@ -60,6 +60,17 @@ Operators can also configure an email allowlist for a self-hosted deployment. When that allowlist is active, a user still needs to pass the normal sign-in rules and have an allowed email address. +## Staying signed in + +Web sign-in sessions last 30 days from sign-in or the last renewal. While you +use the web app, eligible browser requests renew both the session and its +cookie, at most once every 24 hours. Simply leaving a sleeping or closed browser +open does not renew a session. After the session expires, sign in again. + +Signing out, removal by an admin, and password resets still revoke sessions. +Clearing browser cookies or rotating the deployment's session-signing secret +also requires signing in again. Sign out when using a shared device. + ## License and seats A Roomote deployment is free for up to 10 users. Every registered user diff --git a/apps/web/src/app/api/trpc/[trpc]/__tests__/route.test.ts b/apps/web/src/app/api/trpc/[trpc]/__tests__/route.test.ts index ca9e40a4a..de2272594 100644 --- a/apps/web/src/app/api/trpc/[trpc]/__tests__/route.test.ts +++ b/apps/web/src/app/api/trpc/[trpc]/__tests__/route.test.ts @@ -87,6 +87,14 @@ describe('POST /api/trpc/[trpc]', () => { expect(maxDuration).toBe(800); }); + it('enables cookie renewal in the HTTP route handler', async () => { + const response = await call(); + expect(createContextMock).toHaveBeenCalledWith({ + allowSessionRefresh: true, + }); + await response.text(); + }); + it('emits one request-timing line with the auth and handler durations', async () => { const response = await call(); diff --git a/apps/web/src/app/api/trpc/[trpc]/route.ts b/apps/web/src/app/api/trpc/[trpc]/route.ts index acf05814e..cd19723e7 100644 --- a/apps/web/src/app/api/trpc/[trpc]/route.ts +++ b/apps/web/src/app/api/trpc/[trpc]/route.ts @@ -29,7 +29,7 @@ const handler = async (req: Request) => { const contextStartedAt = performance.now(); try { - return await createContext(); + return await createContext({ allowSessionRefresh: true }); } finally { authMs = performance.now() - contextStartedAt; } diff --git a/apps/web/src/lib/server/auth-context.test.ts b/apps/web/src/lib/server/auth-context.test.ts index db24e97bd..ffbc221d8 100644 --- a/apps/web/src/lib/server/auth-context.test.ts +++ b/apps/web/src/lib/server/auth-context.test.ts @@ -174,6 +174,22 @@ describe('authorize', () => { mockUpdateWhere.mockResolvedValue([]); }); + it('does not consume rolling renewal during server rendering', async () => { + await authorize(); + expect(mockGetSession).toHaveBeenCalledWith({ + headers: expect.any(Headers), + query: { disableRefresh: true }, + }); + }); + + it('allows cookie-writable route handlers to renew sessions', async () => { + await authorize({ allowSessionRefresh: true }); + expect(mockGetSession).toHaveBeenCalledWith({ + headers: expect.any(Headers), + query: { disableRefresh: false }, + }); + }); + it('exposes an existing cookie acceptance timestamp', async () => { mockUsersFindFirst.mockResolvedValue({ id: 'user-1', diff --git a/apps/web/src/lib/server/auth-context.ts b/apps/web/src/lib/server/auth-context.ts index 09a279415..297aa263b 100644 --- a/apps/web/src/lib/server/auth-context.ts +++ b/apps/web/src/lib/server/auth-context.ts @@ -54,6 +54,8 @@ type SignedInAuthContext = { type SignedInAuthContextOptions = { treatPendingAsSignedOut?: boolean; + /** Only enable in a Route Handler that can send renewed cookies. */ + allowSessionRefresh?: boolean; }; async function loadDeploymentIdentityState(userId: string) { @@ -314,12 +316,15 @@ function isMatchingUserEntity( ); } -async function getBetterAuthSession() { +async function getBetterAuthSession(allowSessionRefresh = false) { const auth = await getAuth(); try { return await auth.api.getSession({ headers: await headers(), + // Rendering cannot write cookies. Renewing only the database here would + // prevent a later browser request from renewing the cookie for 24 hours. + query: { disableRefresh: !allowSessionRefresh }, }); } catch (error) { // Invalid or expired auth cookies should behave like a signed-out @@ -333,11 +338,11 @@ async function getBetterAuthSession() { } export async function getSignedInAuthContext( - _options?: SignedInAuthContextOptions, + options?: SignedInAuthContextOptions, ): Promise { await bootstrapWebRuntimeEnv(); - const session = await getBetterAuthSession(); + const session = await getBetterAuthSession(options?.allowSessionRefresh); if (!session) { return { success: false, error: 'Unauthorized: User required' }; @@ -414,8 +419,10 @@ export async function getSignedInAuthContext( }; } -export async function authorize(): Promise { - const authContext = await getSignedInAuthContext(); +export async function authorize( + options?: SignedInAuthContextOptions, +): Promise { + const authContext = await getSignedInAuthContext(options); if (!authContext.success) { return authContext; diff --git a/apps/web/src/lib/server/auth-session.integration.test.ts b/apps/web/src/lib/server/auth-session.integration.test.ts new file mode 100644 index 000000000..dad9e21b1 --- /dev/null +++ b/apps/web/src/lib/server/auth-session.integration.test.ts @@ -0,0 +1,238 @@ +import 'next/dist/server/node-environment'; +import { createRequestStoreForAPI } from 'next/dist/server/async-storage/request-store'; +import { + workAsyncStorage, + type WorkStore, +} from 'next/dist/server/app-render/work-async-storage.external'; +import { workUnitAsyncStorage } from 'next/dist/server/app-render/work-unit-async-storage.external'; +import { NextRequest } from 'next/server'; +import { authSessions, authUsers, db, eq } from '@roomote/db/server'; + +vi.mock('./bootstrap-runtime-env', () => ({ bootstrapWebRuntimeEnv: vi.fn() })); +vi.mock('./auth-provider-config', () => ({ + resolveAuthProviderConfig: vi.fn(async () => ({ signature: 'session-test' })), +})); +vi.mock('./better-auth-base-url', () => ({ + getBetterAuthBaseUrlConfig: () => 'https://auth.example.test', +})); +vi.mock('./env', () => ({ + Env: { R_APP_URL: 'https://auth.example.test' }, + getBetterAuthSecret: () => 'test-session-signing-secret-not-for-production', +})); +vi.mock('./access-policy', () => ({ + isNewAuthUserEmailAllowed: async () => true, + isSignInAllowedByAccessPolicy: async () => true, + // Stop after the real session lookup; admission is covered separately. + evaluateSignInAccess: async () => ({ allowed: false }), +})); +vi.mock('./license', () => ({ hasSeatAvailable: async () => true })); +vi.mock('./invite-context', () => ({ + extractInviteTokenFromRequest: () => null, + runWithInviteContext: (_token: unknown, callback: () => unknown) => + callback(), +})); + +import { getAuth } from './auth'; +import { getSignedInAuthContext } from './auth-context'; + +const DAY = 24 * 60 * 60 * 1000; +const NOW = new Date('2026-09-10T12:00:00Z'); +const COOKIE_NAME = '__Secure-better-auth.session_token'; + +describe('browser session renewal with real Better Auth, Next cookies and Postgres', () => { + let cookie: string; + let userId: string; + let sessionId: string; + + async function sessionRow() { + return db.query.authSessions.findFirst({ + where: eq(authSessions.id, sessionId), + }); + } + + // HTML document renders have no RSC header. Use Next's actual read-only + // render phase and mutable Route Handler phase, not a mocked cookie setter. + async function inRequest( + phase: 'render' | 'action', + callback: () => Promise, + rsc = false, + ) { + const url = new URL('https://auth.example.test/sessions'); + const headers = new Headers({ cookie }); + if (rsc) headers.set('RSC', '1'); + const request = new NextRequest(url, { headers }); + const outgoing: string[] = []; + const store = createRequestStoreForAPI( + request, + url, + { tags: [], expirationsByCacheKind: new Map() }, + (cookies) => { + outgoing.splice(0, outgoing.length, ...cookies); + }, + undefined, + ); + store.phase = phase; + const result = await workAsyncStorage.run( + { route: '/sessions', isStaticGeneration: false } as WorkStore, + () => workUnitAsyncStorage.run(store, callback), + ); + return { result, outgoing }; + } + + beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); + const auth = await getAuth(); + const response = await auth.handler( + new Request('https://auth.example.test/api/auth/sign-up/email', { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: 'https://auth.example.test', + }, + body: JSON.stringify({ + name: 'Session Test', + email: `${crypto.randomUUID()}@example.test`, + password: 'test-password-123', + }), + }), + ); + expect(response.status).toBe(200); + userId = (await response.json()).user.id; + cookie = response.headers + .getSetCookie() + .find((value) => value.startsWith(`${COOKIE_NAME}=`))! + .split(';')[0]!; + const row = await db.query.authSessions.findFirst({ + where: eq(authSessions.userId, userId), + }); + sessionId = row!.id; + }); + + afterEach(async () => { + await db.delete(authUsers).where(eq(authUsers.id, userId)); + vi.useRealTimers(); + }); + + it('creates 30-day sessions and retains secure host-only cookies', async () => { + expect((await sessionRow())?.expiresAt).toEqual( + new Date(NOW.getTime() + 30 * DAY), + ); + vi.setSystemTime(new Date(NOW.getTime() + DAY)); + const { outgoing } = await inRequest('action', () => + getSignedInAuthContext({ allowSessionRefresh: true }), + ); + const renewed = outgoing.find((value) => + value.startsWith(`${COOKIE_NAME}=`), + )!; + expect(renewed).toContain('Max-Age=2592000'); + expect(renewed).toContain('HttpOnly'); + expect(renewed).toContain('Secure'); + expect(renewed.toLowerCase()).toContain('samesite=lax'); + expect(renewed).toContain('Path=/'); + expect(renewed.toLowerCase()).not.toContain('domain='); + }); + + it.each([false, true])( + 'leaves a legacy session due during rendering (RSC=%s), then renews DB and cookie together', + async (rsc) => { + const oldExpiry = new Date(NOW.getTime() + 3 * DAY); + await db + .update(authSessions) + .set({ expiresAt: oldExpiry }) + .where(eq(authSessions.id, sessionId)); + const render = await inRequest( + 'render', + () => getSignedInAuthContext(), + rsc, + ); + expect(render.outgoing).toEqual([]); + expect((await sessionRow())?.expiresAt).toEqual(oldExpiry); + + const route = await inRequest('action', () => + getSignedInAuthContext({ allowSessionRefresh: true }), + ); + expect((await sessionRow())?.expiresAt).toEqual( + new Date(NOW.getTime() + 30 * DAY), + ); + expect( + route.outgoing.some((value) => value.includes('Max-Age=2592000')), + ).toBe(true); + + const secondTab = await inRequest('action', () => + getSignedInAuthContext({ allowSessionRefresh: true }), + ); + expect(secondTab.outgoing).toEqual([]); + vi.setSystemTime(new Date(NOW.getTime() + DAY)); + const wake = await inRequest('action', () => + getSignedInAuthContext({ allowSessionRefresh: true }), + ); + expect( + wake.outgoing.some((value) => value.includes('Max-Age=2592000')), + ).toBe(true); + expect((await sessionRow())?.expiresAt).toEqual( + new Date(NOW.getTime() + 31 * DAY), + ); + }, + ); + + it('reproduces the old HTML DB-only renewal to guard the failure scenario', async () => { + vi.setSystemTime(new Date(NOW.getTime() + DAY)); + const auth = await getAuth(); + const render = await inRequest('render', () => + auth.api.getSession({ headers: new Headers({ cookie }) }), + ); + expect(render.result).not.toBeNull(); + expect(render.outgoing).toEqual([]); + expect((await sessionRow())?.expiresAt).toEqual( + new Date(NOW.getTime() + 31 * DAY), + ); + const route = await inRequest('action', () => + getSignedInAuthContext({ allowSessionRefresh: true }), + ); + expect(route.outgoing).toEqual([]); + }); + + it('does not revive expired sessions', async () => { + vi.setSystemTime(new Date(NOW.getTime() + 31 * DAY)); + const { result } = await inRequest('action', () => + getSignedInAuthContext({ allowSessionRefresh: true }), + ); + expect(result).toEqual({ + success: false, + error: 'Unauthorized: User required', + }); + expect(await sessionRow()).toBeUndefined(); + }); + + it('honors administrative deletion immediately', async () => { + await db.delete(authSessions).where(eq(authSessions.id, sessionId)); + const { result } = await inRequest('action', () => + getSignedInAuthContext({ allowSessionRefresh: true }), + ); + expect(result).toEqual({ + success: false, + error: 'Unauthorized: User required', + }); + }); + + it('explicit logout deletes the session and clears its cookie', async () => { + const auth = await getAuth(); + const response = await auth.handler( + new Request('https://auth.example.test/api/auth/sign-out', { + method: 'POST', + headers: { cookie, origin: 'https://auth.example.test' }, + }), + ); + expect(response.status).toBe(200); + expect(await sessionRow()).toBeUndefined(); + expect( + response.headers + .getSetCookie() + .some( + (value) => + value.startsWith(`${COOKIE_NAME}=`) && value.includes('Max-Age=0'), + ), + ).toBe(true); + }); +}); diff --git a/apps/web/src/lib/server/auth.test.ts b/apps/web/src/lib/server/auth.test.ts index 8e9c27874..adaf0ba31 100644 --- a/apps/web/src/lib/server/auth.test.ts +++ b/apps/web/src/lib/server/auth.test.ts @@ -172,6 +172,19 @@ describe('getAuth', () => { expect(options?.session?.freshAge).toBe(0); }); + it('uses a 30-day rolling session without caching revoked sessions', async () => { + await getAuth(); + + const options = mockBetterAuth.mock.calls.at(-1)?.[0]; + expect(options.session).toEqual({ + modelName: 'authSessions', + expiresIn: 30 * 24 * 60 * 60, + updateAge: 24 * 60 * 60, + freshAge: 0, + }); + expect(options.emailAndPassword.revokeSessionsOnPasswordReset).toBe(true); + }); + it('keys the Entra linked-account identity on the normalized uniqueName', async () => { const fetchMock = vi.fn(async (url: string | URL | Request) => { const href = String(url); diff --git a/apps/web/src/lib/server/auth.ts b/apps/web/src/lib/server/auth.ts index 6382408f9..7d96d52c2 100644 --- a/apps/web/src/lib/server/auth.ts +++ b/apps/web/src/lib/server/auth.ts @@ -52,7 +52,10 @@ type AuthSessionResult = { type RoomoteAuth = { api: { - getSession(input: { headers: Headers }): Promise; + getSession(input: { + headers: Headers; + query?: { disableRefresh?: boolean }; + }): Promise; requestPasswordReset(input: { body: { email: string; @@ -1044,6 +1047,8 @@ async function createAuth(authProviderConfig: ResolvedAuthProviderConfig) { }, session: { modelName: 'authSessions', + expiresIn: 30 * 24 * 60 * 60, + updateAge: 24 * 60 * 60, // Better Auth gates account unlinking (and similar operations) behind a // "fresh session" check that defaults to one day, which makes // Settings > Linked Accounts unlink fail with "Session is not fresh" diff --git a/apps/web/src/trpc/__tests__/procedure-timing-wiring.test.ts b/apps/web/src/trpc/__tests__/procedure-timing-wiring.test.ts index e7abcc58a..ccf179912 100644 --- a/apps/web/src/trpc/__tests__/procedure-timing-wiring.test.ts +++ b/apps/web/src/trpc/__tests__/procedure-timing-wiring.test.ts @@ -2,7 +2,12 @@ import { callTRPCProcedure } from '@trpc/server'; import { logger } from '@/lib/server/logger'; -import { createRouter, protectedProcedure, publicProcedure } from '../init'; +import { + createContext, + createRouter, + protectedProcedure, + publicProcedure, +} from '../init'; vi.mock('@/lib/server/logger', () => ({ logger: { @@ -76,6 +81,15 @@ describe('procedure timing wiring', () => { ); }); + it('keeps server-caller contexts read-only unless the HTTP route opts in', async () => { + await createContext(); + expect(authorizeMock).toHaveBeenLastCalledWith(undefined); + await createContext({ allowSessionRefresh: true }); + expect(authorizeMock).toHaveBeenLastCalledWith({ + allowSessionRefresh: true, + }); + }); + it('times failing procedures without changing the error', async () => { await expect(call('boom', { success: true })).rejects.toThrow('kaboom'); diff --git a/apps/web/src/trpc/init.ts b/apps/web/src/trpc/init.ts index 8c2e73135..015f8ed2e 100644 --- a/apps/web/src/trpc/init.ts +++ b/apps/web/src/trpc/init.ts @@ -13,8 +13,10 @@ import { withProcedureTiming } from './request-timing'; // See: // - https://trpc.io/docs/server/context // - https://trpc.io/docs/server/authorization -export const createContext = async () => { - const auth = await authorize(); +export const createContext = async (options?: { + allowSessionRefresh?: boolean; +}) => { + const auth = await authorize(options); return { auth }; };