fix(xai-oauth): preserve refresh_token + release :56121 on cancel

Two lifecycle issues Looper flagged on the prior commit:

1. resolveXAIBearer dropped the existing refresh_token whenever the
   refresh response omitted one. RFC 6749 §6 explicitly allows the
   server to skip refresh_token rotation and keep the old one valid;
   xAI's behaviour is currently to rotate, but a future change could
   silently break OD users. With the old code the first refresh
   succeeded but persisted a token with no refresh credential, so the
   next expiry forced the user back through Sign in even though their
   grant was still good. Carries the previous refresh_token forward
   when fresh.refresh_token is absent. Updates the matching
   xai-credentials test to assert the carried-forward value instead of
   the previous (incorrect) "drop it" assertion.

2. The Cancel button in XaiOAuthControl only cleared React-side
   pending state; the daemon's one-shot 127.0.0.1:56121 listener kept
   running for the full 30 min server timeout. /api/xai/auth/status
   would still report listening=true, and that singleton port could
   block the next Sign in (or a Hermes session on the same machine).
   Adds POST /api/xai/oauth/cancel that calls stopActiveListener()
   without touching the stored token (Disconnect is the destructive
   path; this is the narrow "release the port" affordance), wires the
   UI Cancel handler to fire it, and adds two route tests covering
   the listener-stopped-but-token-preserved invariant and the no-op
   behaviour when no listener is in flight.

All 38 xai tests + tsconfig.tests.json typecheck + web typecheck +
pnpm guard pass.
This commit is contained in:
Joey-nexu
2026-05-19 08:43:38 +08:00
parent 03b8ae7bc2
commit fd0488797c
5 changed files with 114 additions and 9 deletions
+8 -1
View File
@@ -57,7 +57,14 @@ export async function resolveXAIBearer(
tokenType: fresh.token_type ?? 'Bearer',
savedAt: Date.now(),
};
if (fresh.refresh_token) next.refreshToken = fresh.refresh_token;
// Preserve the existing refresh_token when the token endpoint omits
// one in its response. RFC 6749 §6 lets a server skip refresh_token
// rotation and keep the old one valid; if we dropped it here the
// next expiry would have nothing to refresh against and force the
// user back through the full Sign in flow even though their grant
// is still good.
const carriedRefresh = fresh.refresh_token ?? stored.refreshToken;
if (carriedRefresh) next.refreshToken = carriedRefresh;
if (typeof fresh.expires_in === 'number') {
next.expiresAt = Date.now() + fresh.expires_in * 1000;
}
+21
View File
@@ -15,6 +15,9 @@
// when xAI shows a code instead of
// redirecting (the common case for
// loopback redirect_uri)
// POST /api/xai/oauth/cancel — stop the in-flight :56121 listener
// without touching any stored token
// (UI Cancel button)
// GET /api/xai/auth/status — has-token / expiry / in-flight bit
// POST /api/xai/oauth/disconnect — wipe stored token, stop listener
// POST /api/xai/search — search X posts via Grok's native
@@ -194,6 +197,24 @@ export function registerXaiRoutes(app: Express, ctx: RegisterXaiRoutesDeps) {
}
});
app.post('/api/xai/oauth/cancel', async (req, res) => {
if (!isLocalSameOrigin(req, getResolvedPort())) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
// Cancel only stops the in-flight loopback listener. It must NOT
// wipe the stored token — a user clicking Cancel mid-Reconnect
// would otherwise lose their existing SuperGrok grant. Disconnect
// is the destructive path; this one only releases the singleton
// :56121 port so a new Sign in (or Hermes) can grab it.
try {
await stopActiveListener();
res.json({ ok: true });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
res.status(500).json({ error: msg });
}
});
app.post('/api/xai/oauth/disconnect', async (req, res) => {
if (!isLocalSameOrigin(req, getResolvedPort())) {
return res.status(403).json({ error: 'cross-origin request rejected' });
+8 -7
View File
@@ -135,7 +135,7 @@ describe('resolveXAIBearer', () => {
expect(resolved).toBeNull();
});
it('keeps the old refresh_token when the refresh response omits it', async () => {
it('preserves the existing refresh_token when the refresh response omits it', async () => {
const stored: StoredXAIToken = {
accessToken: 'old-bearer',
tokenType: 'Bearer',
@@ -147,8 +147,10 @@ describe('resolveXAIBearer', () => {
const resolved = await resolveXAIBearer(
dataDir,
// Some token endpoints omit refresh_token in the refresh response;
// we should still accept the new access_token but not crash.
// Some token endpoints omit refresh_token in the refresh response
// (RFC 6749 §6 lets the server keep the old one valid).
// resolveXAIBearer must carry the previous refresh_token forward
// so the next expiry can still refresh.
fetchOk({ access_token: 'new-bearer', token_type: 'Bearer' }),
);
expect(resolved?.accessToken).toBe('new-bearer');
@@ -157,10 +159,9 @@ describe('resolveXAIBearer', () => {
const onDisk = JSON.parse(
await readFile(path.join(dataDir, 'xai-tokens.json'), 'utf8'),
);
// Without refresh_token we drop it from disk — that matches the
// wire response. The user will need to re-login if their next call
// also goes expired.
expect(onDisk.token.refreshToken).toBeUndefined();
// Old refresh_token must survive the partial refresh response so
// the next expiry doesn't kick the user back through Sign in.
expect(onDisk.token.refreshToken).toBe('rt-1');
// expires_in not in response → we don't fabricate one.
expect(onDisk.token.expiresAt).toBeUndefined();
});
+59 -1
View File
@@ -442,6 +442,63 @@ describe('xai-routes', () => {
expect((await jsonOf(r)).error).toMatch(/xAI 429/);
});
it('POST /api/xai/oauth/cancel stops the listener without touching the stored token', async () => {
const startResp = await fetch(`${app.baseUrl}/api/xai/oauth/start`, {
method: 'POST',
});
const { state } = await jsonOf(startResp);
globalThis.fetch = vi.fn(async (input: any, init?: any) => {
const url = typeof input === 'string' ? input : input.toString();
if (url === XAI_OAUTH_TOKEN_ENDPOINT) {
return new Response(
JSON.stringify({
access_token: 'persisted-bearer',
token_type: 'Bearer',
expires_in: 3600,
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
return realFetch(input, init);
}) as typeof fetch;
// Stage a real connected state via the paste-back path so we have a
// token on disk, then start a *second* OAuth flow (which opens a
// new listener) and Cancel it.
await onCallbackHolder.current!({ kind: 'ok', code: 'c', state });
let status = await fetch(`${app.baseUrl}/api/xai/auth/status`).then((r) =>
jsonOf(r),
);
expect(status.connected).toBe(true);
// Open another OAuth flow — that opens a new listener.
await fetch(`${app.baseUrl}/api/xai/oauth/start`, { method: 'POST' });
stopMock.mockClear();
const cancelResp = await fetch(`${app.baseUrl}/api/xai/oauth/cancel`, {
method: 'POST',
});
expect(cancelResp.status).toBe(200);
expect((await jsonOf(cancelResp)).ok).toBe(true);
expect(stopMock).toHaveBeenCalled();
// Token survives — Cancel is non-destructive.
status = await fetch(`${app.baseUrl}/api/xai/auth/status`).then((r) =>
jsonOf(r),
);
expect(status.connected).toBe(true);
expect(status.listening).toBe(false);
});
it('POST /api/xai/oauth/cancel is a no-op when no listener is in flight', async () => {
const r = await fetch(`${app.baseUrl}/api/xai/oauth/cancel`, {
method: 'POST',
});
expect(r.status).toBe(200);
expect((await jsonOf(r)).ok).toBe(true);
});
it('POST /api/xai/oauth/disconnect wipes a stored token', async () => {
const startResp = await fetch(`${app.baseUrl}/api/xai/oauth/start`, {
method: 'POST',
@@ -587,10 +644,11 @@ describe('xai-routes — cross-origin guard', () => {
await rm(projectRoot, { recursive: true, force: true });
});
it('rejects all five endpoints when isLocalSameOrigin is false', async () => {
it('rejects all six endpoints when isLocalSameOrigin is false', async () => {
const cases: ReadonlyArray<readonly [string, string]> = [
['POST', '/api/xai/oauth/start'],
['POST', '/api/xai/oauth/complete'],
['POST', '/api/xai/oauth/cancel'],
['GET', '/api/xai/auth/status'],
['POST', '/api/xai/oauth/disconnect'],
['POST', '/api/xai/search'],
@@ -85,6 +85,20 @@ async function disconnectOAuth(): Promise<boolean> {
}
}
async function cancelInFlightOAuth(): Promise<void> {
// Best-effort. If the daemon is unreachable the listener will still
// self-close on its 30 min timeout; we don't surface a failure to
// the user because Cancel is a UX affordance, not a critical action.
try {
await fetch('/api/xai/oauth/cancel', {
method: 'POST',
credentials: 'same-origin',
});
} catch {
// ignore
}
}
async function completeOAuthManual(
state: string,
code: string,
@@ -232,6 +246,10 @@ export function XaiOAuthControl() {
};
const onCancelPending = () => {
// Tell the daemon to stop its one-shot 127.0.0.1:56121 listener so
// the singleton port doesn't sit pinned for the full 30 min server
// timeout. Fire-and-forget — UI state clears immediately either way.
void cancelInFlightOAuth();
setPendingAuthUrl(null);
setPendingState(null);
setPasteCode('');