From bc40399ecc04f2de91bb67490632ff672cbe49d9 Mon Sep 17 00:00:00 2001 From: jacksonkasi1 Date: Fri, 1 May 2026 08:49:35 +0000 Subject: [PATCH] fix(auth): resolve onboarding redirect loop without disabling cookieCache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-enable session.cookieCache (maxAge: 60s) and fix the root cause: when onboarding step handlers wrote directly to DB (sessionTable.activeOrganizationId, userTable.shouldOnboard), the signed cookie cache was never refreshed, so RequireOnboarding guards read stale values and looped back to /onboarding. Fix: - hooks.after now intercepts /onboarding/step/* and /onboarding/skip-step/* paths. After the plugin has run adapter.updateOnboardingState (which sets shouldOnboard=false on the completion step), refreshSessionCookie() re-reads the fresh session from DB via internalAdapter.findSession and re-issues the signed cookie — so the very next guard check sees the updated values. - The createOrganization step handler also calls refreshSessionCookie() inline after writing activeOrganizationId to the session row, giving the client an updated cookie mid-flow (before the completion step). - Added packages/auth/src/utils/refresh-session-cookie.ts helper that wraps internalAdapter.findSession + setSessionCookie with error isolation. --- bun.lock | 1 + packages/auth/src/auth.ts | 26 ++++++- .../auth/src/utils/refresh-session-cookie.ts | 75 +++++++++++++++++++ 3 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 packages/auth/src/utils/refresh-session-cookie.ts diff --git a/bun.lock b/bun.lock index 68268a7..8d5059c 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "flowstack", diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index 5738629..be893b0 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -33,6 +33,7 @@ import { sendOrganizationInvitation } from "./email/send-invitation"; import { sendResetPassword } from "./email/send-reset-password"; import { sendVerificationEmail } from "./email/send-verification-email"; import checkUserRole from "./utils/user-is-admin"; +import { refreshSessionCookie } from "./utils/refresh-session-cookie"; // ** import types import type { Env } from "./types"; @@ -166,9 +167,8 @@ export function configureAuth(env: Env): ReturnType { session: { cookieCache: { - // Prevent stale session fields (e.g. shouldOnboard/activeOrganizationId) - // from causing onboarding/dashboard redirect loops immediately after updates. - enabled: false, + enabled: true, + maxAge: 60, // seconds — short enough that stale data expires quickly }, }, @@ -222,7 +222,20 @@ export function configureAuth(env: Env): ReturnType { hooks: { after: createAuthMiddleware(async (ctx) => { - // Only run on get-session endpoint + // After any onboarding step completes (or is skipped), the plugin has + // already updated shouldOnboard / currentOnboardingStep in the DB. + // Re-issue the signed session cookie so the cookie cache reflects the + // new state immediately — prevents the guard from reading stale values + // and looping back to /onboarding. + if ( + ctx.path.startsWith("/onboarding/step/") || + ctx.path.startsWith("/onboarding/skip-step/") + ) { + await refreshSessionCookie(ctx); + return; + } + + // Only run the rest on get-session endpoint if (ctx.path !== "/get-session") { return; } @@ -474,6 +487,11 @@ export function configureAuth(env: Env): ReturnType { .where(eq(sessionTable.id, session.session.id)); logger.info(`Set org ${orgId} as active for session ${session.session.id}`); + + // Immediately refresh the signed cookie so the cache reflects + // activeOrganizationId without waiting for the hooks.after pass + // (which runs after adapter.updateOnboardingState completes). + await refreshSessionCookie(ctx); } return { organizationId: orgId, organizationName, slug }; diff --git a/packages/auth/src/utils/refresh-session-cookie.ts b/packages/auth/src/utils/refresh-session-cookie.ts new file mode 100644 index 0000000..e228950 --- /dev/null +++ b/packages/auth/src/utils/refresh-session-cookie.ts @@ -0,0 +1,75 @@ +/** + * Refresh the Better Auth session cookie cache after a mutation. + * + * Better Auth caches the session payload (session + user fields) in a signed + * cookie (`session.cookieCache`) so subsequent `/get-session` requests can be + * answered without hitting the database. When we mutate session-bearing rows + * directly (e.g. `sessionTable.activeOrganizationId`, `userTable.shouldOnboard`, + * `userTable.currentOnboardingStep`), the cookie cache becomes stale and + * downstream guards (RequireOnboarding) will read the OLD values until the + * cache expires, causing redirect loops. + * + * This helper re-reads the live session + user from the database via + * `ctx.context.internalAdapter.findSession` and re-issues the signed session + * cookie via Better Auth's `setSessionCookie` helper, which internally calls + * `setCookieCache`. After this runs, the very next request will see the fresh + * session values. + * + * Reference: better-auth v1.4.x `src/cookies/index.ts` (setSessionCookie / + * setCookieCache) and `src/api/routes/update-user.ts` which uses the same + * pattern after `internalAdapter.updateUser`. + */ + +// ** import lib +import { setSessionCookie } from "better-auth/cookies"; + +// ** import logs +import { logger } from "@repo/logs"; + +// ** import types +import type { GenericEndpointContext } from "better-auth"; + +/** + * Re-read the session for the current request from the database and refresh + * the signed session cookie (including the cookie cache). + * + * Safe to call from any authenticated endpoint handler (`use: [sessionMiddleware]`). + * No-op if no session is attached to the request context. + */ +export async function refreshSessionCookie( + ctx: GenericEndpointContext, +): Promise { + try { + const currentSession = ctx.context.session as + | { session?: { token?: string } } + | null + | undefined; + const sessionToken = currentSession?.session?.token; + + if (!sessionToken) { + // No session on this request - nothing to refresh. + return; + } + + const fresh = await ctx.context.internalAdapter.findSession(sessionToken); + + if (!fresh) { + // Session was deleted concurrently - leave cookie alone, /get-session + // will clean it up on the next request. + return; + } + + await setSessionCookie(ctx, { + session: fresh.session, + user: fresh.user, + }); + } catch (error) { + // Never let cookie refresh failures break the mutation - log and continue. + // Worst case: cookie cache is stale for `cookieCache.maxAge` seconds. + logger.error( + `Failed to refresh session cookie cache: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +}