fix: extend OAuth state cookie lifetime to match the 10-minute state TTL (#544)

This commit is contained in:
Ben Senescu 2026-08-26 14:37:46 -04:00 committed by GitHub
parent b32c0bd841
commit 61b32ec647
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 31 additions and 7 deletions

View File

@ -15,6 +15,16 @@ export function createBaseAuthConfig() {
// /api/auth endpoint. Header lookup is case-insensitive.
ipAddressHeaders: ["cf-connecting-ip"],
},
// Better Auth writes the OAuth state verification row with a 10-minute
// expiry but sets the matching signed cookie with maxAge 300, and
// parseGenericState checks the cookie before the row's expiresAt — so the
// intended 10-minute window is unreachable. The GSC and GA4 providers
// below force `select_account consent`, a two-screen Google flow, so a
// user who takes more than 5 minutes returns with a live verification row
// and a dead cookie and fails with "State mismatch: State not persisted
// correctly". The row's expiresAt still enforces the real 10-minute
// window and the state stays single-use, so this is not a weakening.
cookies: { state: { attributes: { maxAge: 600 } } },
},
account: {
// Encrypt OAuth access/refresh tokens at rest in D1. Also covers the

View File

@ -68,14 +68,27 @@ function createAuth() {
const auth = betterAuth({
baseURL: baseUrl,
secret: getHostedSecret(),
// The api-key plugin logs every verification failure at error level — a
// stale key or a throttled caller included. The /mcp handler already logs
// the response it returns at the right level (debug for 401, warn for 429),
// so drop the duplicate.
logger: {
log: (level, message, ...args) => {
log: (level, message, ...args: unknown[]) => {
// The api-key plugin logs every verification failure at error level — a
// stale key or a throttled caller included. The /mcp handler already logs
// the response it returns at the right level (debug for 401, warn for 429),
// so drop the duplicate.
if (message.startsWith("Failed to validate API key")) return;
console[level](`[better-auth] ${message}`, ...args);
// "Failed to parse state" is user/browser behavior: a replayed OAuth
// callback URL (back button, restored tab), or a consent screen left
// open past the state's lifetime. The request already redirects the
// user to an error page; nothing here is on-call actionable.
const effectiveLevel =
level === "error" && message === "Failed to parse state"
? "warn"
: level;
// Also drops Better Auth's ISO-timestamp prefix, which makes every log
// line fingerprint as its own error group in observability.
console[effectiveLevel === "debug" ? "log" : effectiveLevel](
`[better-auth] ${message}`,
...args,
);
},
},
...baseAuthConfig,

View File

@ -103,6 +103,7 @@ export async function handleMcpApiKeyRequest(
// clients (see lib/auth-api-key.ts). Cloudflare's counter is per-colo
// best-effort, which is all this needs to be: credits bound spend, this
// bounds runaway request volume.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the binding is declared in alchemy.run.ts; env stays unknown through the MCP handler chain
const rateLimit = (env as { MCP_RATE_LIMIT?: RateLimit }).MCP_RATE_LIMIT;
if (rateLimit) {
const { success } = await rateLimit.limit({ key: userId });

View File

@ -106,7 +106,7 @@ async function checkBatchLive(
results.push(outcome.value);
return;
}
const reason = outcome.reason;
const reason: unknown = outcome.reason;
const code = reason instanceof AppError ? reason.code : "UNKNOWN";
const message = reason instanceof Error ? reason.message : String(reason);
// DataForSEO erring on its own side is a provider flake, not our bug: the