From 457581f1f9f8268d8c10d6ee18e4ecd0929903c4 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:55:38 +0000 Subject: [PATCH 1/2] fix: renew web sessions reliably and improve sign-in recovery --- apps/docs/users.mdx | 17 ++ .../auth-form.client.test.tsx | 105 +++++++- .../src/app/(unauthenticated)/auth-form.tsx | 40 +-- .../(unauthenticated)/email-password-auth.tsx | 3 +- .../reset-password/page.client.test.tsx | 56 +++++ .../reset-password/page.client.tsx | 17 +- .../api/trpc/[trpc]/__tests__/route.test.ts | 8 + apps/web/src/app/api/trpc/[trpc]/route.ts | 2 +- apps/web/src/lib/auth-redirect.ts | 17 ++ apps/web/src/lib/server/auth-context.test.ts | 16 ++ apps/web/src/lib/server/auth-context.ts | 17 +- .../server/auth-session.integration.test.ts | 238 ++++++++++++++++++ apps/web/src/lib/server/auth.test.ts | 13 + apps/web/src/lib/server/auth.ts | 7 +- .../__tests__/procedure-timing-wiring.test.ts | 16 +- apps/web/src/trpc/init.ts | 6 +- 16 files changed, 542 insertions(+), 36 deletions(-) create mode 100644 apps/web/src/lib/server/auth-session.integration.test.ts diff --git a/apps/docs/users.mdx b/apps/docs/users.mdx index 0a91fa485..8b7b9f8d3 100644 --- a/apps/docs/users.mdx +++ b/apps/docs/users.mdx @@ -60,6 +60,23 @@ 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. + +If you are signed out, use your existing email/password credential or a +configured Slack or Microsoft sign-in option. You do not need a new invite for +an existing account. On the email form, **Other sign-in options** returns to +the provider choices without losing your destination. If you forgot a local +password, ask an admin for a [password reset link](#password-reset-flow). + ## License and seats A Roomote deployment is free for up to 10 users. Every registered user diff --git a/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx b/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx index 110ab4428..a8ec97821 100644 --- a/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx +++ b/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx @@ -1,10 +1,12 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -const { replaceMock, refreshMock, signInOauth2Mock } = vi.hoisted(() => ({ - replaceMock: vi.fn(), - refreshMock: vi.fn(), - signInOauth2Mock: vi.fn(), -})); +const { replaceMock, refreshMock, signInOauth2Mock, signInEmailMock } = + vi.hoisted(() => ({ + replaceMock: vi.fn(), + refreshMock: vi.fn(), + signInOauth2Mock: vi.fn(), + signInEmailMock: vi.fn(), + })); let searchParams = new URLSearchParams(); @@ -20,6 +22,7 @@ vi.mock('@/lib/auth-client', () => ({ authClient: { signIn: { oauth2: signInOauth2Mock, + email: signInEmailMock, }, }, })); @@ -33,7 +36,9 @@ import { AuthForm } from './auth-form'; describe('AuthForm', () => { beforeEach(() => { + vi.clearAllMocks(); searchParams = new URLSearchParams(); + signInEmailMock.mockResolvedValue({ data: {}, error: null }); signInOauth2Mock.mockResolvedValue({ data: { url: 'https://oauth.example.com' }, error: null, @@ -75,8 +80,17 @@ describe('AuthForm', () => { }); }); - it('falls back to setup for unsafe redirect URLs', async () => { - searchParams = new URLSearchParams('redirect_url=https://example.com'); + it.each([ + 'https://example.com', + '//example.com', + '/\\example.com', + '/tasks\\mine', + '/\n/example.com', + '/tasks\t', + '/tasks\u0000', + '/tasks\u007f', + ])('falls back to setup for unsafe redirect URL %j', async (redirectUrl) => { + searchParams = new URLSearchParams({ redirect_url: redirectUrl }); render(); @@ -198,6 +212,82 @@ describe('AuthForm', () => { ).toBeVisible(); expect(screen.getByLabelText('Email')).toBeVisible(); expect(screen.getByLabelText('Password')).toBeVisible(); + expect( + screen.queryByRole('button', { name: 'Other sign-in options' }), + ).not.toBeInTheDocument(); + }); + + it('returns from email to provider options without losing the redirect', async () => { + searchParams = new URLSearchParams({ redirect_url: '/tasks?view=mine' }); + render(); + + fireEvent.click( + screen.getByRole('button', { name: 'Continue with email' }), + ); + fireEvent.click( + screen.getByRole('button', { name: 'Other sign-in options' }), + ); + + expect(screen.queryByLabelText('Email')).not.toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', { name: 'Continue with Microsoft Teams' }), + ); + await waitFor(() => { + expect(signInOauth2Mock).toHaveBeenCalledWith({ + providerId: 'microsoft-entra-id', + callbackURL: '/tasks?view=mine', + }); + }); + expect(searchParams.get('redirect_url')).toBe('/tasks?view=mine'); + }); + + it('shows reset success and signs in with the new password at the return path', async () => { + searchParams = new URLSearchParams({ + password_reset: '1', + invited: '1', + redirect_url: '/tasks?view=mine', + }); + render(); + + expect(screen.getByRole('status')).toHaveTextContent( + 'Your password has been reset. Sign in with your new password.', + ); + expect(screen.queryByLabelText('Name')).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('Email'), { + target: { value: 'person@example.com' }, + }); + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'new-password' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Sign in' })); + await waitFor(() => { + expect(signInEmailMock).toHaveBeenCalledWith({ + email: 'person@example.com', + password: 'new-password', + callbackURL: '/tasks?view=mine', + }); + expect(replaceMock).toHaveBeenCalledWith('/tasks?view=mine'); + }); + }); + + it('allows other sign-in options after a reset', () => { + searchParams = new URLSearchParams('password_reset=1'); + render(); + fireEvent.click( + screen.getByRole('button', { name: 'Other sign-in options' }), + ); + expect( + screen.getByRole('button', { name: 'Continue with Slack' }), + ).toBeVisible(); + }); + + it('does not show reset success for another marker value', () => { + searchParams = new URLSearchParams('password_reset=0'); + render(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Continue with email' }), + ).toBeVisible(); }); it('does not show Telegram as a sign-in provider', () => { @@ -251,6 +341,7 @@ describe('AuthForm', () => { screen.getByText(/Need an account\? Forgot your password\?/), ).toBeVisible(); expect(screen.getByText(/Ask your admin\./)).toBeVisible(); + expect(screen.getByText(/Settings > Users/)).toBeVisible(); expect(screen.getByRole('button', { name: 'Talk to us' })).toBeVisible(); }); diff --git a/apps/web/src/app/(unauthenticated)/auth-form.tsx b/apps/web/src/app/(unauthenticated)/auth-form.tsx index bc9273357..71c690f20 100644 --- a/apps/web/src/app/(unauthenticated)/auth-form.tsx +++ b/apps/web/src/app/(unauthenticated)/auth-form.tsx @@ -9,6 +9,7 @@ import { } from '@roomote/types'; import { authClient } from '@/lib/auth-client'; +import { getSafeRedirectUrl } from '@/lib/auth-redirect'; import { getAuthProviderCallbackUrl } from '@/lib/auth-provider-callback'; import { cn } from '@/lib/utils'; import { OriginMismatchAlert } from '@/components/layout'; @@ -45,18 +46,6 @@ function AuthProviderIcon({ provider }: { provider: AuthProvider }) { return ; } -function getSafeRedirectUrl(rawRedirectUrl: string | null): string { - if (!rawRedirectUrl) { - return '/setup'; - } - - if (!rawRedirectUrl.startsWith('/') || rawRedirectUrl.startsWith('//')) { - return '/setup'; - } - - return rawRedirectUrl; -} - function getAuthErrorMessage( error: { message?: string } | null | undefined, fallback: string, @@ -93,6 +82,7 @@ export function AuthForm({ }) { const router = useRouter(); const searchParams = useSearchParams(); + const passwordReset = searchParams.get('password_reset') === '1'; const redirectUrl = useMemo( () => getSafeRedirectUrl(searchParams.get('redirect_url')), [searchParams], @@ -107,7 +97,7 @@ export function AuthForm({ const hasVisibleProviders = visibleProviders.length > 0; const [errorMessage, setErrorMessage] = useState(null); - const [isEmailAuthVisible, setIsEmailAuthVisible] = useState(false); + const [isEmailAuthVisible, setIsEmailAuthVisible] = useState(passwordReset); const [submittingProvider, setSubmittingProvider] = useState(null); const showEmailAuth = !hasVisibleProviders || isEmailAuthVisible; @@ -161,6 +151,13 @@ export function AuthForm({
+ {passwordReset && ( + + + Your password has been reset. Sign in with your new password. + + + )} {noticeMessage && ( @@ -218,7 +215,7 @@ export function AuthForm({ ) : null} {showEmailAuth ? ( -
+
+ {hasVisibleProviders && ( + + )}
) : null}
diff --git a/apps/web/src/app/(unauthenticated)/email-password-auth.tsx b/apps/web/src/app/(unauthenticated)/email-password-auth.tsx index 3c5fe5c95..143a3a5b7 100644 --- a/apps/web/src/app/(unauthenticated)/email-password-auth.tsx +++ b/apps/web/src/app/(unauthenticated)/email-password-auth.tsx @@ -201,7 +201,8 @@ export function EmailPasswordAuth({

Need an account? Forgot your password?
- Ask your admin. + Ask your admin. They can create a password reset link in Settings + > Users.

{accountLinkHelpText ? ( diff --git a/apps/web/src/app/(unauthenticated)/reset-password/page.client.test.tsx b/apps/web/src/app/(unauthenticated)/reset-password/page.client.test.tsx index 5253e4082..3aed7bc85 100644 --- a/apps/web/src/app/(unauthenticated)/reset-password/page.client.test.tsx +++ b/apps/web/src/app/(unauthenticated)/reset-password/page.client.test.tsx @@ -115,4 +115,60 @@ describe('ResetPasswordPageClient', () => { expect(await screen.findByText('Invalid token')).toBeVisible(); expect(replaceMock).not.toHaveBeenCalled(); }); + + it.each([ + [ + '/tasks?view=mine#latest', + '/sign-in?redirect_url=%2Ftasks%3Fview%3Dmine%23latest', + ], + ['https://example.com', '/sign-in'], + ['//example.com', '/sign-in'], + ['/\\example.com', '/sign-in'], + ['/tasks\\mine', '/sign-in'], + ['/\n/example.com', '/sign-in'], + ['/tasks\t', '/sign-in'], + ['/tasks\u0000', '/sign-in'], + ['/tasks\u007f', '/sign-in'], + ])('returns from an invalid link safely for %j', (redirectUrl, expected) => { + searchParams = new URLSearchParams({ + error: 'INVALID_TOKEN', + redirect_url: redirectUrl, + }); + render(); + expect( + screen.getByRole('link', { name: 'Back to sign in' }), + ).toHaveAttribute('href', expected); + expect(screen.getByText(/Settings > Users/)).toBeVisible(); + }); + + it.each([ + [ + '/tasks?view=mine#latest', + '/sign-in?password_reset=1&redirect_url=%2Ftasks%3Fview%3Dmine%23latest', + ], + ['https://example.com', '/sign-in?password_reset=1'], + ['//example.com', '/sign-in?password_reset=1'], + ['/\\example.com', '/sign-in?password_reset=1'], + ['/tasks\\mine', '/sign-in?password_reset=1'], + ['/\n/example.com', '/sign-in?password_reset=1'], + ['/tasks\t', '/sign-in?password_reset=1'], + ['/tasks\u0000', '/sign-in?password_reset=1'], + ['/tasks\u007f', '/sign-in?password_reset=1'], + ])('returns after reset safely for %j', async (redirectUrl, expected) => { + searchParams = new URLSearchParams({ + token: 'reset-token', + redirect_url: redirectUrl, + }); + render(); + fireEvent.change(screen.getByLabelText('New password'), { + target: { value: 'new-password' }, + }); + fireEvent.change(screen.getByLabelText('Confirm password'), { + target: { value: 'new-password' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Reset password' })); + await waitFor(() => { + expect(replaceMock).toHaveBeenCalledWith(expected); + }); + }); }); diff --git a/apps/web/src/app/(unauthenticated)/reset-password/page.client.tsx b/apps/web/src/app/(unauthenticated)/reset-password/page.client.tsx index 39bfe970f..c22c91295 100644 --- a/apps/web/src/app/(unauthenticated)/reset-password/page.client.tsx +++ b/apps/web/src/app/(unauthenticated)/reset-password/page.client.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { authClient } from '@/lib/auth-client'; +import { getSafeRedirectUrl } from '@/lib/auth-redirect'; import { Alert, AlertCircle, @@ -27,6 +28,12 @@ export function ResetPasswordPageClient() { const searchParams = useSearchParams(); const token = searchParams.get('token'); const error = searchParams.get('error'); + const redirectUrl = getSafeRedirectUrl(searchParams.get('redirect_url'), ''); + const signInParams = new URLSearchParams(); + if (redirectUrl) { + signInParams.set('redirect_url', redirectUrl); + } + const signInUrl = `/sign-in${redirectUrl ? `?${signInParams}` : ''}`; const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [errorMessage, setErrorMessage] = useState(null); @@ -66,7 +73,11 @@ export function ResetPasswordPageClient() { return; } - router.replace('/sign-in?password_reset=1'); + const successParams = new URLSearchParams({ password_reset: '1' }); + if (redirectUrl) { + successParams.set('redirect_url', redirectUrl); + } + router.replace(`/sign-in?${successParams}`); router.refresh(); } catch (resetError) { setErrorMessage( @@ -96,11 +107,11 @@ export function ResetPasswordPageClient() { This reset link is invalid or expired. Ask an admin to create - a new password reset link. + a new password reset link in Settings > Users.
) : ( 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/auth-redirect.ts b/apps/web/src/lib/auth-redirect.ts index 8ee51cfb3..4e64b0f80 100644 --- a/apps/web/src/lib/auth-redirect.ts +++ b/apps/web/src/lib/auth-redirect.ts @@ -28,3 +28,20 @@ export function normalizeAuthRedirect( return undefined; } } + +export function getSafeRedirectUrl( + rawRedirectUrl: string | null, + fallback = '/setup', +): string { + if ( + !rawRedirectUrl?.startsWith('/') || + rawRedirectUrl.startsWith('//') || + // URL parsers strip control characters; reject them before navigation. + // eslint-disable-next-line no-control-regex + /[\\\u0000-\u001f\u007f]/.test(rawRedirectUrl) + ) { + return fallback; + } + + return rawRedirectUrl; +} 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 }; }; From 4c004b66f1ce27f33edf8e39a785c39a05ce270d Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Mon, 7 Sep 2026 03:32:49 +0000 Subject: [PATCH 2/2] fix: scope session renewal PR to backend changes --- apps/docs/users.mdx | 6 - .../auth-form.client.test.tsx | 105 ++---------------- .../src/app/(unauthenticated)/auth-form.tsx | 40 +++---- .../(unauthenticated)/email-password-auth.tsx | 3 +- .../reset-password/page.client.test.tsx | 56 ---------- .../reset-password/page.client.tsx | 17 +-- apps/web/src/lib/auth-redirect.ts | 17 --- 7 files changed, 26 insertions(+), 218 deletions(-) diff --git a/apps/docs/users.mdx b/apps/docs/users.mdx index 8b7b9f8d3..9fc883955 100644 --- a/apps/docs/users.mdx +++ b/apps/docs/users.mdx @@ -71,12 +71,6 @@ 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. -If you are signed out, use your existing email/password credential or a -configured Slack or Microsoft sign-in option. You do not need a new invite for -an existing account. On the email form, **Other sign-in options** returns to -the provider choices without losing your destination. If you forgot a local -password, ask an admin for a [password reset link](#password-reset-flow). - ## License and seats A Roomote deployment is free for up to 10 users. Every registered user diff --git a/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx b/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx index a8ec97821..110ab4428 100644 --- a/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx +++ b/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx @@ -1,12 +1,10 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -const { replaceMock, refreshMock, signInOauth2Mock, signInEmailMock } = - vi.hoisted(() => ({ - replaceMock: vi.fn(), - refreshMock: vi.fn(), - signInOauth2Mock: vi.fn(), - signInEmailMock: vi.fn(), - })); +const { replaceMock, refreshMock, signInOauth2Mock } = vi.hoisted(() => ({ + replaceMock: vi.fn(), + refreshMock: vi.fn(), + signInOauth2Mock: vi.fn(), +})); let searchParams = new URLSearchParams(); @@ -22,7 +20,6 @@ vi.mock('@/lib/auth-client', () => ({ authClient: { signIn: { oauth2: signInOauth2Mock, - email: signInEmailMock, }, }, })); @@ -36,9 +33,7 @@ import { AuthForm } from './auth-form'; describe('AuthForm', () => { beforeEach(() => { - vi.clearAllMocks(); searchParams = new URLSearchParams(); - signInEmailMock.mockResolvedValue({ data: {}, error: null }); signInOauth2Mock.mockResolvedValue({ data: { url: 'https://oauth.example.com' }, error: null, @@ -80,17 +75,8 @@ describe('AuthForm', () => { }); }); - it.each([ - 'https://example.com', - '//example.com', - '/\\example.com', - '/tasks\\mine', - '/\n/example.com', - '/tasks\t', - '/tasks\u0000', - '/tasks\u007f', - ])('falls back to setup for unsafe redirect URL %j', async (redirectUrl) => { - searchParams = new URLSearchParams({ redirect_url: redirectUrl }); + it('falls back to setup for unsafe redirect URLs', async () => { + searchParams = new URLSearchParams('redirect_url=https://example.com'); render(); @@ -212,82 +198,6 @@ describe('AuthForm', () => { ).toBeVisible(); expect(screen.getByLabelText('Email')).toBeVisible(); expect(screen.getByLabelText('Password')).toBeVisible(); - expect( - screen.queryByRole('button', { name: 'Other sign-in options' }), - ).not.toBeInTheDocument(); - }); - - it('returns from email to provider options without losing the redirect', async () => { - searchParams = new URLSearchParams({ redirect_url: '/tasks?view=mine' }); - render(); - - fireEvent.click( - screen.getByRole('button', { name: 'Continue with email' }), - ); - fireEvent.click( - screen.getByRole('button', { name: 'Other sign-in options' }), - ); - - expect(screen.queryByLabelText('Email')).not.toBeInTheDocument(); - fireEvent.click( - screen.getByRole('button', { name: 'Continue with Microsoft Teams' }), - ); - await waitFor(() => { - expect(signInOauth2Mock).toHaveBeenCalledWith({ - providerId: 'microsoft-entra-id', - callbackURL: '/tasks?view=mine', - }); - }); - expect(searchParams.get('redirect_url')).toBe('/tasks?view=mine'); - }); - - it('shows reset success and signs in with the new password at the return path', async () => { - searchParams = new URLSearchParams({ - password_reset: '1', - invited: '1', - redirect_url: '/tasks?view=mine', - }); - render(); - - expect(screen.getByRole('status')).toHaveTextContent( - 'Your password has been reset. Sign in with your new password.', - ); - expect(screen.queryByLabelText('Name')).not.toBeInTheDocument(); - fireEvent.change(screen.getByLabelText('Email'), { - target: { value: 'person@example.com' }, - }); - fireEvent.change(screen.getByLabelText('Password'), { - target: { value: 'new-password' }, - }); - fireEvent.click(screen.getByRole('button', { name: 'Sign in' })); - await waitFor(() => { - expect(signInEmailMock).toHaveBeenCalledWith({ - email: 'person@example.com', - password: 'new-password', - callbackURL: '/tasks?view=mine', - }); - expect(replaceMock).toHaveBeenCalledWith('/tasks?view=mine'); - }); - }); - - it('allows other sign-in options after a reset', () => { - searchParams = new URLSearchParams('password_reset=1'); - render(); - fireEvent.click( - screen.getByRole('button', { name: 'Other sign-in options' }), - ); - expect( - screen.getByRole('button', { name: 'Continue with Slack' }), - ).toBeVisible(); - }); - - it('does not show reset success for another marker value', () => { - searchParams = new URLSearchParams('password_reset=0'); - render(); - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Continue with email' }), - ).toBeVisible(); }); it('does not show Telegram as a sign-in provider', () => { @@ -341,7 +251,6 @@ describe('AuthForm', () => { screen.getByText(/Need an account\? Forgot your password\?/), ).toBeVisible(); expect(screen.getByText(/Ask your admin\./)).toBeVisible(); - expect(screen.getByText(/Settings > Users/)).toBeVisible(); expect(screen.getByRole('button', { name: 'Talk to us' })).toBeVisible(); }); diff --git a/apps/web/src/app/(unauthenticated)/auth-form.tsx b/apps/web/src/app/(unauthenticated)/auth-form.tsx index 71c690f20..bc9273357 100644 --- a/apps/web/src/app/(unauthenticated)/auth-form.tsx +++ b/apps/web/src/app/(unauthenticated)/auth-form.tsx @@ -9,7 +9,6 @@ import { } from '@roomote/types'; import { authClient } from '@/lib/auth-client'; -import { getSafeRedirectUrl } from '@/lib/auth-redirect'; import { getAuthProviderCallbackUrl } from '@/lib/auth-provider-callback'; import { cn } from '@/lib/utils'; import { OriginMismatchAlert } from '@/components/layout'; @@ -46,6 +45,18 @@ function AuthProviderIcon({ provider }: { provider: AuthProvider }) { return ; } +function getSafeRedirectUrl(rawRedirectUrl: string | null): string { + if (!rawRedirectUrl) { + return '/setup'; + } + + if (!rawRedirectUrl.startsWith('/') || rawRedirectUrl.startsWith('//')) { + return '/setup'; + } + + return rawRedirectUrl; +} + function getAuthErrorMessage( error: { message?: string } | null | undefined, fallback: string, @@ -82,7 +93,6 @@ export function AuthForm({ }) { const router = useRouter(); const searchParams = useSearchParams(); - const passwordReset = searchParams.get('password_reset') === '1'; const redirectUrl = useMemo( () => getSafeRedirectUrl(searchParams.get('redirect_url')), [searchParams], @@ -97,7 +107,7 @@ export function AuthForm({ const hasVisibleProviders = visibleProviders.length > 0; const [errorMessage, setErrorMessage] = useState(null); - const [isEmailAuthVisible, setIsEmailAuthVisible] = useState(passwordReset); + const [isEmailAuthVisible, setIsEmailAuthVisible] = useState(false); const [submittingProvider, setSubmittingProvider] = useState(null); const showEmailAuth = !hasVisibleProviders || isEmailAuthVisible; @@ -151,13 +161,6 @@ export function AuthForm({
- {passwordReset && ( - - - Your password has been reset. Sign in with your new password. - - - )} {noticeMessage && ( @@ -215,7 +218,7 @@ export function AuthForm({ ) : null} {showEmailAuth ? ( -
+
- {hasVisibleProviders && ( - - )}
) : null}
diff --git a/apps/web/src/app/(unauthenticated)/email-password-auth.tsx b/apps/web/src/app/(unauthenticated)/email-password-auth.tsx index 143a3a5b7..3c5fe5c95 100644 --- a/apps/web/src/app/(unauthenticated)/email-password-auth.tsx +++ b/apps/web/src/app/(unauthenticated)/email-password-auth.tsx @@ -201,8 +201,7 @@ export function EmailPasswordAuth({

Need an account? Forgot your password?
- Ask your admin. They can create a password reset link in Settings - > Users. + Ask your admin.

{accountLinkHelpText ? ( diff --git a/apps/web/src/app/(unauthenticated)/reset-password/page.client.test.tsx b/apps/web/src/app/(unauthenticated)/reset-password/page.client.test.tsx index 3aed7bc85..5253e4082 100644 --- a/apps/web/src/app/(unauthenticated)/reset-password/page.client.test.tsx +++ b/apps/web/src/app/(unauthenticated)/reset-password/page.client.test.tsx @@ -115,60 +115,4 @@ describe('ResetPasswordPageClient', () => { expect(await screen.findByText('Invalid token')).toBeVisible(); expect(replaceMock).not.toHaveBeenCalled(); }); - - it.each([ - [ - '/tasks?view=mine#latest', - '/sign-in?redirect_url=%2Ftasks%3Fview%3Dmine%23latest', - ], - ['https://example.com', '/sign-in'], - ['//example.com', '/sign-in'], - ['/\\example.com', '/sign-in'], - ['/tasks\\mine', '/sign-in'], - ['/\n/example.com', '/sign-in'], - ['/tasks\t', '/sign-in'], - ['/tasks\u0000', '/sign-in'], - ['/tasks\u007f', '/sign-in'], - ])('returns from an invalid link safely for %j', (redirectUrl, expected) => { - searchParams = new URLSearchParams({ - error: 'INVALID_TOKEN', - redirect_url: redirectUrl, - }); - render(); - expect( - screen.getByRole('link', { name: 'Back to sign in' }), - ).toHaveAttribute('href', expected); - expect(screen.getByText(/Settings > Users/)).toBeVisible(); - }); - - it.each([ - [ - '/tasks?view=mine#latest', - '/sign-in?password_reset=1&redirect_url=%2Ftasks%3Fview%3Dmine%23latest', - ], - ['https://example.com', '/sign-in?password_reset=1'], - ['//example.com', '/sign-in?password_reset=1'], - ['/\\example.com', '/sign-in?password_reset=1'], - ['/tasks\\mine', '/sign-in?password_reset=1'], - ['/\n/example.com', '/sign-in?password_reset=1'], - ['/tasks\t', '/sign-in?password_reset=1'], - ['/tasks\u0000', '/sign-in?password_reset=1'], - ['/tasks\u007f', '/sign-in?password_reset=1'], - ])('returns after reset safely for %j', async (redirectUrl, expected) => { - searchParams = new URLSearchParams({ - token: 'reset-token', - redirect_url: redirectUrl, - }); - render(); - fireEvent.change(screen.getByLabelText('New password'), { - target: { value: 'new-password' }, - }); - fireEvent.change(screen.getByLabelText('Confirm password'), { - target: { value: 'new-password' }, - }); - fireEvent.click(screen.getByRole('button', { name: 'Reset password' })); - await waitFor(() => { - expect(replaceMock).toHaveBeenCalledWith(expected); - }); - }); }); diff --git a/apps/web/src/app/(unauthenticated)/reset-password/page.client.tsx b/apps/web/src/app/(unauthenticated)/reset-password/page.client.tsx index c22c91295..39bfe970f 100644 --- a/apps/web/src/app/(unauthenticated)/reset-password/page.client.tsx +++ b/apps/web/src/app/(unauthenticated)/reset-password/page.client.tsx @@ -5,7 +5,6 @@ import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { authClient } from '@/lib/auth-client'; -import { getSafeRedirectUrl } from '@/lib/auth-redirect'; import { Alert, AlertCircle, @@ -28,12 +27,6 @@ export function ResetPasswordPageClient() { const searchParams = useSearchParams(); const token = searchParams.get('token'); const error = searchParams.get('error'); - const redirectUrl = getSafeRedirectUrl(searchParams.get('redirect_url'), ''); - const signInParams = new URLSearchParams(); - if (redirectUrl) { - signInParams.set('redirect_url', redirectUrl); - } - const signInUrl = `/sign-in${redirectUrl ? `?${signInParams}` : ''}`; const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [errorMessage, setErrorMessage] = useState(null); @@ -73,11 +66,7 @@ export function ResetPasswordPageClient() { return; } - const successParams = new URLSearchParams({ password_reset: '1' }); - if (redirectUrl) { - successParams.set('redirect_url', redirectUrl); - } - router.replace(`/sign-in?${successParams}`); + router.replace('/sign-in?password_reset=1'); router.refresh(); } catch (resetError) { setErrorMessage( @@ -107,11 +96,11 @@ export function ResetPasswordPageClient() { This reset link is invalid or expired. Ask an admin to create - a new password reset link in Settings > Users. + a new password reset link.
) : ( diff --git a/apps/web/src/lib/auth-redirect.ts b/apps/web/src/lib/auth-redirect.ts index 4e64b0f80..8ee51cfb3 100644 --- a/apps/web/src/lib/auth-redirect.ts +++ b/apps/web/src/lib/auth-redirect.ts @@ -28,20 +28,3 @@ export function normalizeAuthRedirect( return undefined; } } - -export function getSafeRedirectUrl( - rawRedirectUrl: string | null, - fallback = '/setup', -): string { - if ( - !rawRedirectUrl?.startsWith('/') || - rawRedirectUrl.startsWith('//') || - // URL parsers strip control characters; reject them before navigation. - // eslint-disable-next-line no-control-regex - /[\\\u0000-\u001f\u007f]/.test(rawRedirectUrl) - ) { - return fallback; - } - - return rawRedirectUrl; -}