diff --git a/docs/AUTH-ROTATION.md b/docs/AUTH-ROTATION.md index 418fafa..9457df1 100644 --- a/docs/AUTH-ROTATION.md +++ b/docs/AUTH-ROTATION.md @@ -94,3 +94,7 @@ printf %s "$NEW" | npx wrangler secret put BETTER_AUTH_SECRET # then With `FLOCKSTACK_API_KEY` in the environment (never an argument) it also lists the tools as that key. Exit code 1 when a check fails, with the status and the start of the body of the answer (that is where the router's "The app is not answering" shows). It cannot issue a token, which needs a person signing in, so it cannot see a signing key that fails to decrypt; connecting one client is that test. `node scripts/deploy-all.mjs` runs it after each region's router is deployed, as a warning that never stops the run (it asks again after a pause; a key for the other region is not used). `--dry-run` prints what it would run. + +## Refresh tokens used twice + +Refresh tokens rotate on use. Several MCP client sessions can share one login, so a token another session already used reaches the server again. For 10 minutes (`REFRESH_REPLAY_SECONDS` in `lib/auth.js`; Better Auth's default is 30 seconds) the server answers it with the tokens the first refresh issued. Past that, it refuses with `invalid_grant` and drops every token of that login for the client, so each session has to connect again. Each refusal is logged as `[auth] A refresh_token grant was refused as invalid_grant for `; if "reconnect" prompts come back, look for those lines (`tests/oauth-refresh-replay.test.mjs`). diff --git a/lib/auth.js b/lib/auth.js index 5da48f4..c82d0ab 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -92,6 +92,11 @@ const OAUTH_DISABLED_PATHS = [ // An idle connection signs out after this; every refresh starts it again. const REFRESH_TOKEN_SECONDS = 90 * 24 * 3600; +// Refresh tokens rotate on use. A client holding a refresh token another process of the same login already used (several Claude Code +// sessions share one) gets the answer the first one got, for this long; past it, Better Auth drops every token of the login and +// each session has to connect again. The default of 30 seconds was too short for sessions that refresh minutes apart. +const REFRESH_REPLAY_SECONDS = 600; + const REGISTRATIONS_PER_IP = { name: "oauth-registrations", limit: 10, window: "1 h" }; // Better Auth checks a registered app's redirect URIs as a web app's, https only. So it registers the https ones, @@ -323,6 +328,7 @@ async function createAuth() { scopes: OAUTH_SCOPES, grantTypes: ["authorization_code", "refresh_token"], refreshTokenExpiresIn: REFRESH_TOKEN_SECONDS, + refreshTokenReuseInterval: REFRESH_REPLAY_SECONDS, allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, // Token requests from ChatGPT and Claude come from a few addresses for all their users. Registrations @@ -378,6 +384,19 @@ function unavailable() { ); } +// A refresh the server refused as invalid_grant is how a client ends up asking its user to connect again (and, past the replay window, +// how a login's tokens are all dropped): logged with the grant and the client's public id, never a token. +async function logRefusedGrant(request, response) { + try { + const { error } = await response.clone().json(); + if (error !== "invalid_grant") return; + const form = new URLSearchParams(await request.text()); + console.warn(`[auth] A ${form.get("grant_type") ?? "token"} grant was refused as invalid_grant for ${form.get("client_id") ?? "an unknown client"}`); + } catch { + // Logging never changes the answer. + } +} + // Better Auth's own routes: sessions, Google, Apple, OAuth and the discovery documents under /.well-known. Whatever goes wrong // (the database, a key nobody can decrypt, an error Better Auth did not catch) is logged and answered with JSON, never thrown: a // Worker that throws is a 502 from the router. @@ -385,7 +404,9 @@ export async function handleAuthRequest(request) { try { const auth = await getAuth(); const cookies = withCurrentCookies(request.headers); + const tokenRequest = new URL(request.url).pathname.endsWith("/oauth2/token") ? request.clone() : null; const response = await auth.handler(cookies === request.headers ? request : new Request(request, { headers: cookies })); + if (tokenRequest && response.status === 400) await logRefusedGrant(tokenRequest, response); if (response.status < 500) return response; console.error(`[auth] ${request.method} ${new URL(request.url).pathname} answered ${response.status}`); await response.body?.cancel(); diff --git a/tests/oauth-refresh-replay.test.mjs b/tests/oauth-refresh-replay.test.mjs new file mode 100644 index 0000000..3b000ea --- /dev/null +++ b/tests/oauth-refresh-replay.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { after, before, describe, mock, test } from "node:test"; +import mongoose from "@/lib/docdb"; +import { activate, startTestApp } from "./helpers/app.mjs"; +import { AGENT, MCP_URL, createOAuthHarness, loadOAuthRoutes } from "./helpers/oauth-flow.mjs"; + +// Several MCP client sessions share one login, so a refresh token that was already used reaches the server again. +let app; +let h; + +const refresh = (refreshToken) => h.postToken({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: AGENT.clientId, resource: MCP_URL }); + +async function connected(email) { + const user = await h.customer(email); + await activate(user); + const { client, provider } = await h.connectApp(user); + await client.close(); + return provider.saved.tokens; +} + +const refreshTokens = () => mongoose.connection.db.collection("oauthRefreshToken"); + +before(async () => { + app = await startTestApp(); + h = createOAuthHarness(await loadOAuthRoutes()); +}); + +after(() => app?.stop()); + +describe("refresh token reuse", () => { + test("a used token is answered again for ten minutes, with the tokens the first refresh issued", async () => { + const tokens = await connected("replay-window@example.com"); + const first = await refresh(tokens.refresh_token); + assert.equal(first.status, 200, JSON.stringify(first.body)); + + const [row] = await refreshTokens().find({ rotationReplayExpiresAt: { $exists: true } }).toArray(); + const seconds = (new Date(row.rotationReplayExpiresAt) - new Date(row.rotatedAt ?? row.revoked)) / 1000; + assert.ok(seconds > 590 && seconds <= 600, `the replay window is ten minutes, not ${seconds} seconds`); + + const again = await refresh(tokens.refresh_token); + assert.equal(again.status, 200, JSON.stringify(again.body)); + assert.equal(again.body.refresh_token, first.body.refresh_token, "the same answer, not a second rotation"); + assert.equal((await h.mcpRequest(again.body.access_token)).status, 200); + }); + + test("past the window the refusal is logged and the login's tokens are dropped", async () => { + const tokens = await connected("replay-expired@example.com"); + const first = await refresh(tokens.refresh_token); + assert.equal(first.status, 200, JSON.stringify(first.body)); + await refreshTokens().updateMany({ rotatedAt: { $exists: true } }, { $set: { rotationReplayExpiresAt: new Date(Date.now() - 1000) } }); + + const warn = mock.method(console, "warn", () => {}); + try { + const stale = await refresh(tokens.refresh_token); + assert.equal(stale.status, 400); + assert.equal(stale.body.error, "invalid_grant"); + const logged = warn.mock.calls.map(({ arguments: args }) => args.join(" ")); + assert.ok(logged.some((line) => line.includes("refresh_token grant was refused as invalid_grant") && line.includes(AGENT.clientId)), logged.join("\n")); + assert.ok(!logged.some((line) => line.includes(tokens.refresh_token)), "no token in the log"); + } finally { + warn.mock.restore(); + } + + assert.equal((await refresh(first.body.refresh_token)).status, 400, "the whole login has to connect again"); + }); +});