Skip to content
Merged
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
4 changes: 4 additions & 0 deletions docs/AUTH-ROTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <client>`; if "reconnect" prompts come back, look for those lines (`tests/oauth-refresh-replay.test.mjs`).
21 changes: 21 additions & 0 deletions lib/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -378,14 +384,29 @@ 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.
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();
Expand Down
66 changes: 66 additions & 0 deletions tests/oauth-refresh-replay.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading