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
32 changes: 32 additions & 0 deletions apps/ade-cli/src/headlessLinearServices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
85 changes: 66 additions & 19 deletions apps/ade-cli/src/headlessLinearServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<void> => {
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;
Expand Down Expand Up @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions apps/desktop/src/main/services/cto/linearAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
101 changes: 74 additions & 27 deletions apps/desktop/src/main/services/cto/linearCredentialService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<void> => {
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;
});
Expand Down
Loading
Loading