diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 68bd8f97b..367350ee4 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -603,6 +603,38 @@ describe("headlessLinearServices", () => { } }); + it("clears headless OAuth credentials when forced refresh gets invalid_grant without rotation", async () => { + const previousAdeHome = process.env.ADE_HOME; + const previousFetch = globalThis.fetch; + process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-linear-forced-refresh-")); + const fetchImpl = vi.fn(async () => ({ + ok: false, + status: 400, + json: async () => ({ error: "invalid_grant" }), + })) as unknown as typeof fetch; + globalThis.fetch = fetchImpl; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-linear-forced-project-")); + const adeDir = path.join(projectRoot, ".ade"); + const services = createHeadlessLinearServices(createDeps({ projectRoot, adeDir })); + try { + services.linearCredentialService.setOAuthToken({ + accessToken: "at_rejected", + refreshToken: "rt_dead", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); + + await services.linearCredentialService.ensureFreshToken({ force: true }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(services.linearCredentialService.getStatus().tokenStored).toBe(false); + } finally { + services.dispose(); + globalThis.fetch = previousFetch; + if (previousAdeHome == null) delete process.env.ADE_HOME; + else process.env.ADE_HOME = previousAdeHome; + } + }); + it("assigns CTO default title for cto identityKey", async () => { const services = createHeadlessLinearServices(createDeps()); diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 705787f97..d95be2a7a 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -58,9 +58,14 @@ import { createPrService as createPrServiceImpl } from "../../desktop/src/main/s import { createAutomationSecretService as createAutomationSecretServiceImpl } from "../../desktop/src/main/services/automations/automationSecretService"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; import { + linearInvalidGrantLikelyStaleRotation, linearTokenNeedsRefresh, refreshLinearOAuthAccessToken, } from "../../desktop/src/main/services/cto/linearTokenRefresh"; +import { + LinearOAuthRefreshLockTimeoutError, + withLinearOAuthRefreshLock, +} from "../../desktop/src/main/services/cto/linearOAuthRefreshLock"; // Keep headless runtimes aligned with the desktop credential service so packaged // alpha builds can offer the same PKCE-based Linear sign-in flow. @@ -1143,9 +1148,11 @@ export function createHeadlessGitHubService( function createHeadlessLinearCredentialService(args: { adeDir: string; + logger?: Logger; }): HeadlessLinearCredentialService { + const secretsDir = path.join(args.adeDir, "secrets"); const credentialStore = new EncryptedFileCredentialStore({ - secretsDir: path.join(args.adeDir, "secrets"), + secretsDir, }); const tokenKey = "linear.token.v1"; const authModeKey = "linear.authMode.v1"; @@ -1250,23 +1257,60 @@ function createHeadlessLinearCredentialService(args: { const client = readOAuthClientCredentials(); if (!client) return; refreshInFlight = (async () => { - const result = await refreshLinearOAuthAccessToken({ - refreshToken, - clientId: client.clientId, - clientSecret: client.clientSecret, - }); - if (result.ok) { - tokenOverride = result.accessToken; - writeCredential(tokenKey, result.accessToken); - writeCredential(authModeKey, "oauth"); - writeCredential(refreshTokenKey, result.refreshToken ?? refreshToken); - writeCredential(tokenExpiresAtKey, result.expiresAt); - } else if (result.invalidGrant) { - tokenOverride = ""; - writeCredential(tokenKey, null); - writeCredential(authModeKey, null); - writeCredential(refreshTokenKey, null); - writeCredential(tokenExpiresAtKey, null); + const performRefresh = async (tokenToRefresh: string): Promise => { + const result = await refreshLinearOAuthAccessToken({ + refreshToken: tokenToRefresh, + clientId: client.clientId, + clientSecret: client.clientSecret, + }); + if (result.ok) { + tokenOverride = result.accessToken; + writeCredential(tokenKey, result.accessToken); + writeCredential(authModeKey, "oauth"); + writeCredential(refreshTokenKey, result.refreshToken ?? tokenToRefresh); + writeCredential(tokenExpiresAtKey, result.expiresAt); + return; + } + if (result.invalidGrant) { + const rereadRefresh = readCredential(refreshTokenKey); + const rereadExpires = readCredential(tokenExpiresAtKey); + if ( + linearInvalidGrantLikelyStaleRotation({ + attemptedRefreshToken: tokenToRefresh, + rereadRefreshToken: rereadRefresh, + rereadExpiresAt: rereadExpires, + trustFreshExpiresAt: !opts?.force, + }) + ) { + tokenOverride = readCredential(tokenKey); + return; + } + tokenOverride = ""; + writeCredential(tokenKey, null); + writeCredential(authModeKey, null); + writeCredential(refreshTokenKey, null); + writeCredential(tokenExpiresAtKey, null); + return; + } + }; + + try { + await withLinearOAuthRefreshLock(secretsDir, async () => { + const latestRefresh = readCredential(refreshTokenKey); + if (!latestRefresh) return; + if ( + !opts?.force + && !linearTokenNeedsRefresh(readCredential(tokenExpiresAtKey), Date.now()) + ) { + return; + } + await performRefresh(latestRefresh); + }); + } catch (error: unknown) { + if (!(error instanceof LinearOAuthRefreshLockTimeoutError)) throw error; + args.logger?.warn("linear_sync.oauth_refresh_lock_timeout", { + message: error.message, + }); } })().finally(() => { refreshInFlight = null; @@ -1689,7 +1733,10 @@ export function createHeadlessLinearServices( logger: args.logger, }); const linearCredentialService = - createHeadlessLinearCredentialService({ adeDir: args.adeDir }) as any; + createHeadlessLinearCredentialService({ + adeDir: args.adeDir, + logger: args.logger, + }) as any; const githubService = createHeadlessGitHubService( args.projectRoot, args.logger, diff --git a/apps/desktop/src/main/services/cto/linearAuth.test.ts b/apps/desktop/src/main/services/cto/linearAuth.test.ts index 40fb594e4..1305620e1 100644 --- a/apps/desktop/src/main/services/cto/linearAuth.test.ts +++ b/apps/desktop/src/main/services/cto/linearAuth.test.ts @@ -1272,6 +1272,68 @@ describe("linearCredentialService OAuth token refresh", () => { expect(service.getStatus().tokenStored).toBe(false); }); + it("keeps the connection when invalid_grant follows a concurrent refresh rotation", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-refresh-race-")); + const store = new MemoryCredentialStore(); + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + const body = String(init?.body ?? ""); + if (body.includes("refresh_token=rt_old")) { + store.setSync("linear.token.v1", "at_from_peer"); + store.setSync("linear.authMode.v1", "oauth"); + store.setSync("linear.refreshToken.v1", "rt_new"); + store.setSync( + "linear.tokenExpiresAt.v1", + new Date(Date.now() + 60 * 60 * 1000).toISOString(), + ); + return { + ok: false, + status: 400, + json: async () => ({ error: "invalid_grant" }), + }; + } + return okResponse({ access_token: "at", refresh_token: "rt", expires_in: 86399 }); + }); + const service = createLinearCredentialService({ + adeDir: path.join(root, ".ade"), + logger: createLogger(), + credentialStore: store, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + service.setOAuthToken({ + accessToken: "at_old", + refreshToken: "rt_old", + expiresAt: new Date(Date.now() - 1000).toISOString(), + }); + + await service.ensureFreshToken(); + + expect(service.getToken()).toBe("at_from_peer"); + expect(service.getStatus()).toMatchObject({ + tokenStored: true, + authMode: "oauth", + refreshTokenStored: true, + }); + }); + + it("does not keep a forced-refresh invalid_grant just because the recorded expiry is fresh", async () => { + const fetchImpl = vi.fn(async () => ({ + ok: false, + status: 400, + json: async () => ({ error: "invalid_grant" }), + })); + const service = makeService(fetchImpl); + service.setOAuthToken({ + accessToken: "at_rejected", + refreshToken: "rt_dead", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); + + await service.ensureFreshToken({ force: true }); + + expect(service.getToken()).toBeNull(); + expect(service.getStatus().tokenStored).toBe(false); + }); + it("keeps the existing token on a transient refresh failure", async () => { const fetchImpl = vi.fn(async () => ({ ok: false, diff --git a/apps/desktop/src/main/services/cto/linearCredentialService.ts b/apps/desktop/src/main/services/cto/linearCredentialService.ts index 5e7624db0..bb83ebc2f 100644 --- a/apps/desktop/src/main/services/cto/linearCredentialService.ts +++ b/apps/desktop/src/main/services/cto/linearCredentialService.ts @@ -6,9 +6,14 @@ import type { Logger } from "../logging/logger"; import { isRecord, getErrorMessage, isEnoentError } from "../shared/utils"; import type { SyncCredentialStore } from "../../../../../ade-cli/src/services/credentials/credentialStore"; import { + linearInvalidGrantLikelyStaleRotation, linearTokenNeedsRefresh, refreshLinearOAuthAccessToken, } from "./linearTokenRefresh"; +import { + LinearOAuthRefreshLockTimeoutError, + withLinearOAuthRefreshLock, +} from "./linearOAuthRefreshLock"; // Bundled OAuth client ID — ships with ADE so users get "Sign in with Linear" // out of the box without configuring their own OAuth app. @@ -530,39 +535,81 @@ export function createLinearCredentialService(args: LinearCredentialServiceArgs) if (!client) return; const refreshToken = stored.refreshToken; refreshInFlight = (async () => { - const result = await refreshLinearOAuthAccessToken({ - refreshToken, - clientId: client.clientId, - clientSecret: client.clientSecret, - fetchImpl: args.fetchImpl, - }); - if (result.ok) { - persistToken({ - token: result.accessToken, - authMode: "oauth", - refreshToken: result.refreshToken ?? refreshToken, - expiresAt: result.expiresAt, - }); - invalidateCache(); - args.logger?.info("linear_sync.oauth_token_refreshed", { - expiresAt: result.expiresAt, + const performRefresh = async (tokenToRefresh: string): Promise => { + const result = await refreshLinearOAuthAccessToken({ + refreshToken: tokenToRefresh, + clientId: client.clientId, + clientSecret: client.clientSecret, + fetchImpl: args.fetchImpl, }); - } else if (result.invalidGrant) { - // Refresh token revoked/expired — clear the connection so the UI prompts - // the user to reconnect rather than retrying a dead token forever. - persistToken(null); - invalidateCache(); - args.logger?.warn("linear_sync.oauth_refresh_invalid_grant", { - status: result.status, - message: result.message, - }); - } else { - // Transient (network / 5xx): keep the token and let the caller retry. + if (result.ok) { + persistToken({ + token: result.accessToken, + authMode: "oauth", + refreshToken: result.refreshToken ?? tokenToRefresh, + expiresAt: result.expiresAt, + }); + invalidateCache(); + args.logger?.info("linear_sync.oauth_token_refreshed", { + expiresAt: result.expiresAt, + }); + return; + } + if (result.invalidGrant) { + invalidateCache(); + const reread = getStoredToken(); + if ( + linearInvalidGrantLikelyStaleRotation({ + attemptedRefreshToken: tokenToRefresh, + rereadRefreshToken: reread?.refreshToken, + rereadExpiresAt: reread?.expiresAt, + trustFreshExpiresAt: !opts?.force, + }) + ) { + args.logger?.info("linear_sync.oauth_refresh_rotated_elsewhere", { + status: result.status, + message: result.message, + }); + return; + } + persistToken(null); + invalidateCache(); + args.logger?.warn("linear_sync.oauth_refresh_invalid_grant", { + status: result.status, + message: result.message, + }); + return; + } args.logger?.warn("linear_sync.oauth_refresh_failed", { status: result.status, message: result.message, }); + }; + + if (credentialStore) { + try { + await withLinearOAuthRefreshLock(secretsDir, async () => { + invalidateCache(); + const latest = getStoredToken(); + if ( + !latest + || latest.authMode !== "oauth" + || !latest.refreshToken + || (!opts?.force && !linearTokenNeedsRefresh(latest.expiresAt, Date.now())) + ) { + return; + } + await performRefresh(latest.refreshToken); + }); + } catch (error: unknown) { + if (!(error instanceof LinearOAuthRefreshLockTimeoutError)) throw error; + args.logger?.warn("linear_sync.oauth_refresh_lock_timeout", { + message: error.message, + }); + } + return; } + await performRefresh(refreshToken); })().finally(() => { refreshInFlight = null; }); diff --git a/apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts b/apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts new file mode 100644 index 000000000..3ba2ea253 --- /dev/null +++ b/apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import path from "node:path"; + +const LOCK_FILE = "linear-oauth-refresh.lock"; +const LOCK_TIMEOUT_MS = 15_000; +const LOCK_STALE_MS = 10_000; +const LOCK_RETRY_MS = 25; + +export class LinearOAuthRefreshLockTimeoutError extends Error { + constructor() { + super("Timed out waiting for Linear OAuth refresh lock."); + this.name = "LinearOAuthRefreshLockTimeoutError"; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function removeStaleLock(lockPath: string): void { + try { + const stat = fs.statSync(lockPath); + if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) { + fs.unlinkSync(lockPath); + } + } catch { + // ignore + } +} + +/** + * Serialize Linear OAuth refresh across ADE runtimes (desktop + ade serve) that + * share the same encrypted credential store. Linear rotates refresh tokens on + * each exchange, so concurrent refreshes can cause the loser to get + * invalid_grant and wipe a still-valid connection. + */ +export async function withLinearOAuthRefreshLock( + secretsDir: string, + fn: () => Promise, +): Promise { + fs.mkdirSync(secretsDir, { recursive: true }); + const lockPath = path.join(secretsDir, LOCK_FILE); + const deadline = Date.now() + LOCK_TIMEOUT_MS; + let fd: number | null = null; + + while (fd === null) { + try { + fd = fs.openSync(lockPath, "wx"); + fs.writeFileSync( + fd, + JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }), + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") throw error; + removeStaleLock(lockPath); + if (Date.now() >= deadline) { + throw new LinearOAuthRefreshLockTimeoutError(); + } + await sleep(LOCK_RETRY_MS); + } + } + + try { + return await fn(); + } finally { + try { + fs.closeSync(fd); + } catch { + // ignore + } + try { + fs.unlinkSync(lockPath); + } catch { + // ignore + } + } +} diff --git a/apps/desktop/src/main/services/cto/linearTokenRefresh.test.ts b/apps/desktop/src/main/services/cto/linearTokenRefresh.test.ts index 08855ba69..ac05720e9 100644 --- a/apps/desktop/src/main/services/cto/linearTokenRefresh.test.ts +++ b/apps/desktop/src/main/services/cto/linearTokenRefresh.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { LINEAR_OAUTH_TOKEN_URL, + linearInvalidGrantLikelyStaleRotation, linearTokenNeedsRefresh, refreshLinearOAuthAccessToken, } from "./linearTokenRefresh"; @@ -33,6 +34,44 @@ describe("linearTokenNeedsRefresh", () => { }); }); +describe("linearInvalidGrantLikelyStaleRotation", () => { + it("detects a rotated refresh token in the shared store", () => { + expect(linearInvalidGrantLikelyStaleRotation({ + attemptedRefreshToken: "rt_old", + rereadRefreshToken: "rt_new", + rereadExpiresAt: null, + })).toBe(true); + }); + + it("detects a freshly renewed access token in the shared store", () => { + expect(linearInvalidGrantLikelyStaleRotation({ + attemptedRefreshToken: "rt_same", + rereadRefreshToken: "rt_same", + rereadExpiresAt: new Date(NOW + 60 * 60 * 1000).toISOString(), + nowMs: NOW, + })).toBe(true); + }); + + it("does not trust a fresh expiry when the caller forced refresh after auth failed", () => { + expect(linearInvalidGrantLikelyStaleRotation({ + attemptedRefreshToken: "rt_same", + rereadRefreshToken: "rt_same", + rereadExpiresAt: new Date(NOW + 60 * 60 * 1000).toISOString(), + trustFreshExpiresAt: false, + nowMs: NOW, + })).toBe(false); + }); + + it("returns false when the store still reflects the dead refresh attempt", () => { + expect(linearInvalidGrantLikelyStaleRotation({ + attemptedRefreshToken: "rt_dead", + rereadRefreshToken: "rt_dead", + rereadExpiresAt: new Date(NOW - 1000).toISOString(), + nowMs: NOW, + })).toBe(false); + }); +}); + describe("refreshLinearOAuthAccessToken", () => { const base = { refreshToken: "rt_old", clientId: "client-123" }; diff --git a/apps/desktop/src/main/services/cto/linearTokenRefresh.ts b/apps/desktop/src/main/services/cto/linearTokenRefresh.ts index 54006f1a3..e421f84d4 100644 --- a/apps/desktop/src/main/services/cto/linearTokenRefresh.ts +++ b/apps/desktop/src/main/services/cto/linearTokenRefresh.ts @@ -29,6 +29,39 @@ export function linearTokenNeedsRefresh( return nowMs >= expMs - bufferMs; } +/** + * True when an invalid_grant response is likely because another ADE runtime + * already rotated the refresh token in the shared credential store — not + * because the user's connection is actually dead. + * + * A fresh stored expiry only counts as evidence for proactive refreshes. During + * a forced refresh after a 401, the access token was already rejected, so expiry + * alone should not keep a dead refresh token around. + */ +export function linearInvalidGrantLikelyStaleRotation(args: { + attemptedRefreshToken: string; + rereadRefreshToken: string | null | undefined; + rereadExpiresAt: string | null | undefined; + trustFreshExpiresAt?: boolean; + nowMs?: number; +}): boolean { + const nowMs = args.nowMs ?? Date.now(); + if ( + args.rereadRefreshToken + && args.rereadRefreshToken !== args.attemptedRefreshToken + ) { + return true; + } + if ( + args.trustFreshExpiresAt !== false + && args.rereadExpiresAt + && !linearTokenNeedsRefresh(args.rereadExpiresAt, nowMs) + ) { + return true; + } + return false; +} + export type LinearTokenRefreshResult = | { ok: true; diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index 98246169a..e7b824827 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -1432,7 +1432,7 @@ describe("local runtime connection pool", () => { gitOriginUrl: null, }); (pool as unknown as { connection: Promise }).connection = Promise.resolve({ - client: { call }, + client: { call, isClosed: () => false }, child: null, socketPath: "/tmp/ade.sock", });