From 8ddec6831bb75d99d9e08dffc6d83b7cf5727e02 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Mon, 16 Mar 2026 22:29:17 -0700 Subject: [PATCH] fix(foundry): deduplicate OAuth callbacks and cache actor handles to fix production auth The production proxy chain (Cloudflare -> Fastly -> Railway) retries OAuth callback requests when they take >10s. The first request succeeds and deletes the verification record, so the retry fails with "verification not found" -> ?error=please_restart_the_process. - Add callback deduplication by OAuth state param in the auth handler. Duplicate requests wait for the original and return a cloned response. - Cache appOrganization() and getUser() actor handles to eliminate redundant getOrCreate RPCs during callbacks (was 10+ per sign-in). - Add diagnostic logging for auth callback timing and adapter operations. Co-Authored-By: Claude Opus 4.6 (1M context) --- foundry/packages/backend/src/index.ts | 48 +++++++ .../backend/src/services/better-auth.ts | 136 +++++++++++++----- 2 files changed, 145 insertions(+), 39 deletions(-) diff --git a/foundry/packages/backend/src/index.ts b/foundry/packages/backend/src/index.ts index 8f82d8b..e00abaa 100644 --- a/foundry/packages/backend/src/index.ts +++ b/foundry/packages/backend/src/index.ts @@ -228,7 +228,55 @@ export async function startBackend(options: BackendStartOptions = {}): Promise Fastly -> Railway) retries callback requests when they take + // >10s. The first request deletes the verification record on success, so the + // retry fails with "verification not found" -> ?error=please_restart_the_process. + // This map tracks in-flight callbacks by state param so retries wait for and + // reuse the first request's response. + const inflightCallbacks = new Map>(); + app.all("/v1/auth/*", async (c) => { + const authPath = c.req.path; + const authMethod = c.req.method; + const isCallback = authPath.includes("/callback/"); + + // Deduplicate callback requests by OAuth state parameter + if (isCallback) { + const url = new URL(c.req.url); + const state = url.searchParams.get("state"); + if (state) { + const existing = inflightCallbacks.get(state); + if (existing) { + logger.info({ path: authPath, state: state.slice(0, 8) + "..." }, "auth_callback_dedup"); + const original = await existing; + return original.clone(); + } + + const promise = (async () => { + logger.info({ path: authPath, method: authMethod, state: state.slice(0, 8) + "..." }, "auth_callback_start"); + const start = performance.now(); + const response = await betterAuth.auth.handler(c.req.raw); + const durationMs = Math.round((performance.now() - start) * 100) / 100; + const location = response.headers.get("location"); + logger.info({ path: authPath, status: response.status, durationMs, location: location ?? undefined }, "auth_callback_complete"); + if (location && location.includes("error=")) { + logger.error({ path: authPath, status: response.status, durationMs, location }, "auth_callback_error_redirect"); + } + return response; + })(); + + inflightCallbacks.set(state, promise); + try { + const response = await promise; + return response.clone(); + } finally { + // Keep entry briefly so late retries still hit the cache + setTimeout(() => inflightCallbacks.delete(state), 30_000); + } + } + } + return await betterAuth.auth.handler(c.req.raw); }); diff --git a/foundry/packages/backend/src/services/better-auth.ts b/foundry/packages/backend/src/services/better-auth.ts index d8a6959..0db6b23 100644 --- a/foundry/packages/backend/src/services/better-auth.ts +++ b/foundry/packages/backend/src/services/better-auth.ts @@ -79,17 +79,33 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin // getOrCreate is intentional here: the adapter runs during Better Auth callbacks // which can fire before any explicit create path. The app organization and user // actors must exist by the time the adapter needs them. - const appOrganization = () => - actorClient.organization.getOrCreate(organizationKey(APP_SHELL_ORGANIZATION_ID), { - createWithInput: APP_SHELL_ORGANIZATION_ID, - }); + // + // Handles are cached to avoid redundant getOrCreate RPCs during a single OAuth + // callback (which calls the adapter 5-10+ times). The RivetKit handle is a + // lightweight proxy; caching it just avoids repeated gateway round-trips. + let cachedAppOrganization: any = null; + const appOrganization = async () => { + if (!cachedAppOrganization) { + cachedAppOrganization = await actorClient.organization.getOrCreate(organizationKey(APP_SHELL_ORGANIZATION_ID), { + createWithInput: APP_SHELL_ORGANIZATION_ID, + }); + } + return cachedAppOrganization; + }; // getOrCreate is intentional: Better Auth creates user records during OAuth // callbacks, so the user actor must be lazily provisioned on first access. - const getUser = async (userId: string) => - await actorClient.user.getOrCreate(userKey(userId), { - createWithInput: { userId }, - }); + const userHandleCache = new Map(); + const getUser = async (userId: string) => { + let handle = userHandleCache.get(userId); + if (!handle) { + handle = await actorClient.user.getOrCreate(userKey(userId), { + createWithInput: { userId }, + }); + userHandleCache.set(userId, handle); + } + return handle; + }; const adapter = createAdapterFactory({ config: { @@ -167,51 +183,91 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin create: async ({ model, data }) => { const transformed = await transformInput(data, model, "create", true); if (model === "verification") { - const organization = await appOrganization(); - return await organization.betterAuthCreateVerification({ data: transformed }); + const start = performance.now(); + try { + const organization = await appOrganization(); + const result = await organization.betterAuthCreateVerification({ data: transformed }); + logger.info( + { model, identifier: transformed.identifier, durationMs: Math.round((performance.now() - start) * 100) / 100 }, + "auth_adapter_create_verification", + ); + return result; + } catch (error) { + logger.error( + { model, identifier: transformed.identifier, durationMs: Math.round((performance.now() - start) * 100) / 100, error: String(error) }, + "auth_adapter_create_verification_error", + ); + throw error; + } } + const createStart = performance.now(); const userId = await resolveUserIdForQuery(model, undefined, transformed); if (!userId) { throw new Error(`Unable to resolve auth actor for create(${model})`); } - const userActor = await getUser(userId); - const created = await userActor.betterAuthCreateRecord({ model, data: transformed }); - const organization = await appOrganization(); + try { + const userActor = await getUser(userId); + const created = await userActor.betterAuthCreateRecord({ model, data: transformed }); + const organization = await appOrganization(); - if (model === "user" && typeof transformed.email === "string" && transformed.email.length > 0) { - await organization.betterAuthUpsertEmailIndex({ - email: transformed.email.toLowerCase(), - userId, - }); + if (model === "user" && typeof transformed.email === "string" && transformed.email.length > 0) { + await organization.betterAuthUpsertEmailIndex({ + email: transformed.email.toLowerCase(), + userId, + }); + } + + if (model === "session") { + await organization.betterAuthUpsertSessionIndex({ + sessionId: String(created.id), + sessionToken: String(created.token), + userId, + }); + } + + if (model === "account") { + await organization.betterAuthUpsertAccountIndex({ + id: String(created.id), + providerId: String(created.providerId), + accountId: String(created.accountId), + userId, + }); + } + + logger.info({ model, userId, durationMs: Math.round((performance.now() - createStart) * 100) / 100 }, "auth_adapter_create_record"); + return (await transformOutput(created, model)) as any; + } catch (error) { + logger.error( + { model, userId, durationMs: Math.round((performance.now() - createStart) * 100) / 100, error: String(error) }, + "auth_adapter_create_record_error", + ); + throw error; } - - if (model === "session") { - await organization.betterAuthUpsertSessionIndex({ - sessionId: String(created.id), - sessionToken: String(created.token), - userId, - }); - } - - if (model === "account") { - await organization.betterAuthUpsertAccountIndex({ - id: String(created.id), - providerId: String(created.providerId), - accountId: String(created.accountId), - userId, - }); - } - - return (await transformOutput(created, model)) as any; }, findOne: async ({ model, where, join }) => { const transformedWhere = transformWhereClause({ model, where, action: "findOne" }); if (model === "verification") { - const organization = await appOrganization(); - return await organization.betterAuthFindOneVerification({ where: transformedWhere, join }); + const start = performance.now(); + try { + const organization = await appOrganization(); + const result = await organization.betterAuthFindOneVerification({ where: transformedWhere, join }); + const identifier = transformedWhere?.find((entry: any) => entry.field === "identifier")?.value; + logger.info( + { model, identifier, found: !!result, durationMs: Math.round((performance.now() - start) * 100) / 100 }, + "auth_adapter_find_verification", + ); + return result; + } catch (error) { + const identifier = transformedWhere?.find((entry: any) => entry.field === "identifier")?.value; + logger.error( + { model, identifier, durationMs: Math.round((performance.now() - start) * 100) / 100, error: String(error) }, + "auth_adapter_find_verification_error", + ); + throw error; + } } const userId = await resolveUserIdForQuery(model, transformedWhere); @@ -373,6 +429,8 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin delete: async ({ model, where }) => { const transformedWhere = transformWhereClause({ model, where, action: "delete" }); if (model === "verification") { + const identifier = transformedWhere?.find((entry: any) => entry.field === "identifier")?.value; + logger.info({ model, identifier }, "auth_adapter_delete_verification"); const organization = await appOrganization(); await organization.betterAuthDeleteVerification({ where: transformedWhere }); return;