mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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('');
|
||||
|
||||
Reference in New Issue
Block a user