Skip to content
Open
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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 22 additions & 4 deletions packages/auth/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -166,9 +167,8 @@ export function configureAuth(env: Env): ReturnType<typeof betterAuth> {

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
},
},

Expand Down Expand Up @@ -222,7 +222,20 @@ export function configureAuth(env: Env): ReturnType<typeof betterAuth> {

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;
}
Expand Down Expand Up @@ -474,6 +487,11 @@ export function configureAuth(env: Env): ReturnType<typeof betterAuth> {
.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 };
Expand Down
75 changes: 75 additions & 0 deletions packages/auth/src/utils/refresh-session-cookie.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}`,
);
}
Comment on lines +66 to +74
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Log the full error object instead of only the message to preserve stack traces and structured data.

Interpolating only error.message/String(error) discards the stack and any structured fields. If your logger supports it, prefer passing the error object itself, e.g. logger.error("Failed to refresh session cookie cache", { error }) or logger.error(error, "Failed to refresh session cookie cache"), so you retain full diagnostics when issues occur.

Suggested change
} 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)
}`,
);
}
} 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 });
}

}