mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 07:04:48 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
4ca77e4d83
commit
8ddec6831b
2 changed files with 145 additions and 39 deletions
|
|
@ -228,7 +228,55 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
||||||
return session?.session?.id ?? null;
|
return session?.session?.id ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Deduplicate OAuth callback requests. The production proxy chain
|
||||||
|
// (Cloudflare -> 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<string, Promise<Response>>();
|
||||||
|
|
||||||
app.all("/v1/auth/*", async (c) => {
|
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);
|
return await betterAuth.auth.handler(c.req.raw);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,17 +79,33 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
||||||
// getOrCreate is intentional here: the adapter runs during Better Auth callbacks
|
// getOrCreate is intentional here: the adapter runs during Better Auth callbacks
|
||||||
// which can fire before any explicit create path. The app organization and user
|
// which can fire before any explicit create path. The app organization and user
|
||||||
// actors must exist by the time the adapter needs them.
|
// actors must exist by the time the adapter needs them.
|
||||||
const appOrganization = () =>
|
//
|
||||||
actorClient.organization.getOrCreate(organizationKey(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,
|
createWithInput: APP_SHELL_ORGANIZATION_ID,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
return cachedAppOrganization;
|
||||||
|
};
|
||||||
|
|
||||||
// getOrCreate is intentional: Better Auth creates user records during OAuth
|
// getOrCreate is intentional: Better Auth creates user records during OAuth
|
||||||
// callbacks, so the user actor must be lazily provisioned on first access.
|
// callbacks, so the user actor must be lazily provisioned on first access.
|
||||||
const getUser = async (userId: string) =>
|
const userHandleCache = new Map<string, any>();
|
||||||
await actorClient.user.getOrCreate(userKey(userId), {
|
const getUser = async (userId: string) => {
|
||||||
|
let handle = userHandleCache.get(userId);
|
||||||
|
if (!handle) {
|
||||||
|
handle = await actorClient.user.getOrCreate(userKey(userId), {
|
||||||
createWithInput: { userId },
|
createWithInput: { userId },
|
||||||
});
|
});
|
||||||
|
userHandleCache.set(userId, handle);
|
||||||
|
}
|
||||||
|
return handle;
|
||||||
|
};
|
||||||
|
|
||||||
const adapter = createAdapterFactory({
|
const adapter = createAdapterFactory({
|
||||||
config: {
|
config: {
|
||||||
|
|
@ -167,15 +183,31 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
||||||
create: async ({ model, data }) => {
|
create: async ({ model, data }) => {
|
||||||
const transformed = await transformInput(data, model, "create", true);
|
const transformed = await transformInput(data, model, "create", true);
|
||||||
if (model === "verification") {
|
if (model === "verification") {
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
const organization = await appOrganization();
|
const organization = await appOrganization();
|
||||||
return await organization.betterAuthCreateVerification({ data: transformed });
|
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);
|
const userId = await resolveUserIdForQuery(model, undefined, transformed);
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new Error(`Unable to resolve auth actor for create(${model})`);
|
throw new Error(`Unable to resolve auth actor for create(${model})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const userActor = await getUser(userId);
|
const userActor = await getUser(userId);
|
||||||
const created = await userActor.betterAuthCreateRecord({ model, data: transformed });
|
const created = await userActor.betterAuthCreateRecord({ model, data: transformed });
|
||||||
const organization = await appOrganization();
|
const organization = await appOrganization();
|
||||||
|
|
@ -204,14 +236,38 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info({ model, userId, durationMs: Math.round((performance.now() - createStart) * 100) / 100 }, "auth_adapter_create_record");
|
||||||
return (await transformOutput(created, model)) as any;
|
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;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
findOne: async ({ model, where, join }) => {
|
findOne: async ({ model, where, join }) => {
|
||||||
const transformedWhere = transformWhereClause({ model, where, action: "findOne" });
|
const transformedWhere = transformWhereClause({ model, where, action: "findOne" });
|
||||||
if (model === "verification") {
|
if (model === "verification") {
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
const organization = await appOrganization();
|
const organization = await appOrganization();
|
||||||
return await organization.betterAuthFindOneVerification({ where: transformedWhere, join });
|
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);
|
const userId = await resolveUserIdForQuery(model, transformedWhere);
|
||||||
|
|
@ -373,6 +429,8 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
||||||
delete: async ({ model, where }) => {
|
delete: async ({ model, where }) => {
|
||||||
const transformedWhere = transformWhereClause({ model, where, action: "delete" });
|
const transformedWhere = transformWhereClause({ model, where, action: "delete" });
|
||||||
if (model === "verification") {
|
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();
|
const organization = await appOrganization();
|
||||||
await organization.betterAuthDeleteVerification({ where: transformedWhere });
|
await organization.betterAuthDeleteVerification({ where: transformedWhere });
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue