From dfd62ff5a0ae8380c107b504d2765b82e81517f6 Mon Sep 17 00:00:00 2001 From: Theo Tarr Date: Thu, 16 Jul 2026 12:26:48 -0400 Subject: [PATCH] Fix LinkedIn auth lifecycle --- src/platforms/core/auth/chromium-worker.ts | 7 ++ src/platforms/core/auth/service.test.ts | 80 +++++++++++++- src/platforms/core/auth/service.ts | 16 ++- src/platforms/core/runner.test.ts | 27 ++++- src/platforms/core/runner.ts | 45 ++++++-- .../core/state/integration-state.test.ts | 59 ++++++++++ src/platforms/core/state/mutations.ts | 34 ++++++ src/platforms/core/sync.ts | 3 + src/platforms/linkedin/api/client.ts | 39 ++++--- src/platforms/linkedin/api/contacts.ts | 6 +- src/platforms/linkedin/api/conversations.ts | 23 ++-- src/platforms/linkedin/api/messages.ts | 18 +-- src/platforms/linkedin/api/reactions.ts | 6 +- src/platforms/linkedin/api/request.test.ts | 18 +++ src/platforms/linkedin/api/request.ts | 22 ++-- .../linkedin/auth/keychain-import.test.ts | 103 ++++++++++++++++++ .../linkedin/auth/keychain-import.ts | 48 ++++++++ .../linkedin/auth/session-store.test.ts | 13 +++ src/platforms/linkedin/auth/session-store.ts | 2 + .../linkedin/realtime/session.test.ts | 34 +++++- src/platforms/linkedin/realtime/session.ts | 14 ++- src/platforms/linkedin/sync/bundle.ts | 1 + src/platforms/linkedin/sync/worker.ts | 2 + src/runtime/daemon/server.test.ts | 28 +++++ src/runtime/daemon/server.ts | 25 ++++- 25 files changed, 594 insertions(+), 79 deletions(-) create mode 100644 src/platforms/linkedin/auth/keychain-import.test.ts create mode 100644 src/platforms/linkedin/auth/session-store.test.ts diff --git a/src/platforms/core/auth/chromium-worker.ts b/src/platforms/core/auth/chromium-worker.ts index dd85d2db..20cc5f9d 100644 --- a/src/platforms/core/auth/chromium-worker.ts +++ b/src/platforms/core/auth/chromium-worker.ts @@ -11,6 +11,10 @@ declare const localStorage: { getItem(key: string): string | null; }; +declare const navigator: { + userAgent: string; +}; + type Platform = "slack" | "linkedin" | "discord"; type AuthState = "authenticated" | "failed" | "cancelled"; @@ -389,6 +393,7 @@ async function extractLinkedInAuth( return null; } + const userAgent = await page.evaluate(() => navigator.userAgent); const captured = await captureLinkedInSessionData(context, page); const realtimeReady = Boolean( captured.pageInstance && @@ -413,6 +418,7 @@ async function extractLinkedInAuth( secure: cookie.secure, sameSite: cookie.sameSite, })), + userAgent, pageInstance: captured.pageInstance, xLiTrack: captured.xLiTrack, serviceVersion: captured.serviceVersion, @@ -424,6 +430,7 @@ async function extractLinkedInAuth( provider: "linkedin", runtime: runtimeKind(), cookieCount: cookies.length, + userAgentCaptured: userAgent.length > 0, realtimeReady, pageInstanceCaptured: Boolean(captured.pageInstance), xLiTrackCaptured: Boolean(captured.xLiTrack), diff --git a/src/platforms/core/auth/service.test.ts b/src/platforms/core/auth/service.test.ts index 28911a0a..1a558e3e 100644 --- a/src/platforms/core/auth/service.test.ts +++ b/src/platforms/core/auth/service.test.ts @@ -8,6 +8,8 @@ import type { SlackHelperInspection } from "../../slack/helper/binary.js"; import { IntegrationAuthService } from "./service.js"; const importSlackDesktopAuthMock = vi.hoisted(() => vi.fn()); +const importLinkedInStoredAuthMock = vi.hoisted(() => vi.fn()); +const validateLinkedInStoredAuthMock = vi.hoisted(() => vi.fn()); const inspectSlackHelperMock = vi.hoisted(() => vi.fn<() => SlackHelperInspection>(() => ({ helperPath: "/tmp/cued-slack-helper", @@ -33,7 +35,8 @@ vi.mock("../../slack/helper/binary.js", () => ({ })); vi.mock("../../linkedin/auth/keychain-import.js", () => ({ - importLinkedInStoredAuth: vi.fn(), + importLinkedInStoredAuth: importLinkedInStoredAuthMock, + validateLinkedInStoredAuth: validateLinkedInStoredAuthMock, })); vi.mock("./runtime.js", () => ({ @@ -85,6 +88,27 @@ describe("IntegrationAuthService", () => { }); } + function upsertAuthenticatedLinkedIn(db: CuedDatabase) { + db.upsertIntegrationState({ + platform: "linkedin", + accountKey: "default", + displayName: "LinkedIn", + authState: "authenticated", + enabled: true, + connectionKind: "browser-session", + syncCapable: true, + launchStrategy: "chromium-auth", + launchTarget: "https://www.linkedin.com/login", + importedFrom: "local-cli", + metadata: { + keychainService: "so.cued.desktop.auth.linkedin", + keychainAccount: "default", + browserProfileDir: "/tmp/cued/linkedin/default", + runtimeKind: "chromium", + }, + }); + } + it("includes the capabilities subcommand in usage text", () => { expect(IntegrationAuthService.usageText()).toContain("status | capabilities | refresh"); }); @@ -393,6 +417,60 @@ describe("IntegrationAuthService", () => { db.close(); }); + it("reuses LinkedIn auth only after the stored session validates", async () => { + const db = createDb(); + upsertAuthenticatedLinkedIn(db); + validateLinkedInStoredAuthMock.mockResolvedValue({ status: "valid" }); + + const service = new IntegrationAuthService(db); + const result = await service.connectManaged("linkedin", "default", new Map()); + + expect(validateLinkedInStoredAuthMock).toHaveBeenCalledWith("default"); + expect(result.integration.authState).toBe("authenticated"); + expect(startAuthSessionMock).not.toHaveBeenCalled(); + + db.close(); + }); + + it("launches LinkedIn auth when the stored session is stale", async () => { + const db = createDb(); + upsertAuthenticatedLinkedIn(db); + validateLinkedInStoredAuthMock.mockResolvedValue({ status: "invalid" }); + + const service = new IntegrationAuthService(db); + const activeAuthSessions = new Map< + string, + { child: ChildProcess; platform: "linkedin"; accountKey: string } + >(); + const result = await service.connectManaged("linkedin", "default", activeAuthSessions); + + expect(validateLinkedInStoredAuthMock).toHaveBeenCalledWith("default"); + expect(result.integration.authState).toBe("in_progress"); + expect(startAuthSessionMock).toHaveBeenCalledTimes(1); + expect(activeAuthSessions.size).toBe(1); + + db.close(); + }); + + it("does not launch LinkedIn auth when session validation is indeterminate", async () => { + const db = createDb(); + upsertAuthenticatedLinkedIn(db); + validateLinkedInStoredAuthMock.mockResolvedValue({ + status: "indeterminate", + errorSummary: "network unavailable", + }); + + const service = new IntegrationAuthService(db); + + await expect(service.connectManaged("linkedin", "default", new Map())).rejects.toThrow( + "Could not verify the existing LinkedIn session: network unavailable", + ); + expect(startAuthSessionMock).not.toHaveBeenCalled(); + expect(db.getIntegrationState("linkedin", "default")?.auth_state).toBe("authenticated"); + + db.close(); + }); + it("persists privacy-safe WhatsApp QR auth failures", async () => { const db = createDb(); runAuthSessionSyncMock.mockResolvedValue({ diff --git a/src/platforms/core/auth/service.ts b/src/platforms/core/auth/service.ts index 20800549..6b753324 100644 --- a/src/platforms/core/auth/service.ts +++ b/src/platforms/core/auth/service.ts @@ -7,7 +7,10 @@ import { sendTelemetryEventSafe, } from "../../../telemetry/context.js"; import { inspectGoogleOAuthClientConfig } from "../../gmail/oauth/client.js"; -import { importLinkedInStoredAuth } from "../../linkedin/auth/keychain-import.js"; +import { + importLinkedInStoredAuth, + validateLinkedInStoredAuth, +} from "../../linkedin/auth/keychain-import.js"; import { importSlackDesktopAuth } from "../../slack/auth/desktop-import.js"; import { resolveIntegrationAccountKey } from "../account-keys.js"; import { @@ -446,6 +449,17 @@ export class IntegrationAuthService { if (!keychainService || !keychainAccount) { return null; } + if (reusable.platform === "linkedin") { + const validation = await validateLinkedInStoredAuth(reusable.accountKey); + if (validation.status === "invalid") { + return null; + } + if (validation.status === "indeterminate") { + throw new Error( + `Could not verify the existing LinkedIn session: ${validation.errorSummary}`, + ); + } + } const previousAuthResult = typeof metadata.authResult === "object" && metadata.authResult ? (metadata.authResult as Record) diff --git a/src/platforms/core/runner.test.ts b/src/platforms/core/runner.test.ts index 6bbd8029..0941e1e6 100644 --- a/src/platforms/core/runner.test.ts +++ b/src/platforms/core/runner.test.ts @@ -22,7 +22,7 @@ vi.mock("./registry.js", () => ({ getAdapterDefinition: getAdapterDefinitionMock, })); -import { runAdapter } from "./runner.js"; +import { AdapterWorkerError, runAdapter } from "./runner.js"; class MockChild extends EventEmitter { stdout = new EventEmitter(); @@ -70,6 +70,31 @@ describe("adapter runner", () => { expect(child.kill).not.toHaveBeenCalled(); }); + it("preserves structured worker error codes across the process boundary", async () => { + const child = new MockChild(); + spawnMock.mockReturnValue(child); + + const promise = runAdapter("slack", "workspace-a"); + child.stdout.emit( + "data", + Buffer.from( + JSON.stringify({ + ok: false, + error: "Authentication failed: 302 Found", + errorCode: "auth_invalid", + }), + ), + ); + child.emit("close", 1); + + const error = await promise.catch((caught) => caught); + expect(error).toBeInstanceOf(AdapterWorkerError); + expect(error).toMatchObject({ + message: "Authentication failed: 302 Found", + code: "auth_invalid", + }); + }); + it("times out hung workers and kills the child process", async () => { vi.useFakeTimers(); const child = new MockChild(); diff --git a/src/platforms/core/runner.ts b/src/platforms/core/runner.ts index 789ee0ed..f85546d4 100644 --- a/src/platforms/core/runner.ts +++ b/src/platforms/core/runner.ts @@ -2,7 +2,24 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import type { AdapterPlatform } from "../../core/types/provider.js"; import { getAdapterDefinition } from "./registry.js"; -import type { AdapterWorkerOutput, SyncBundle } from "./sync.js"; +import type { AdapterWorkerErrorCode, AdapterWorkerOutput, SyncBundle } from "./sync.js"; + +export class AdapterWorkerError extends Error { + constructor( + message: string, + public readonly code: AdapterWorkerErrorCode | null = null, + ) { + super(message); + this.name = "AdapterWorkerError"; + } +} + +export function isAdapterWorkerError( + error: unknown, + code: AdapterWorkerErrorCode, +): error is AdapterWorkerError { + return error instanceof AdapterWorkerError && error.code === code; +} export async function runAdapter( platform: AdapterPlatform, @@ -67,9 +84,14 @@ export async function runAdapter( clearTimeout(timeout); if (code !== 0) { - const parsedError = parseWorkerError(stdout); - const workerError = parsedError ?? stderr.trim(); - reject(new Error(workerError || `Adapter worker exited with code ${code}`)); + const parsedFailure = parseWorkerFailure(stdout); + const workerError = parsedFailure?.message ?? stderr.trim(); + reject( + new AdapterWorkerError( + workerError || `Adapter worker exited with code ${code}`, + parsedFailure?.code ?? null, + ), + ); return; } @@ -86,7 +108,12 @@ export async function runAdapter( } if (!parsed.ok || !parsed.bundle) { - reject(new Error(parsed.error ?? "Adapter worker failed without output")); + reject( + new AdapterWorkerError( + parsed.error ?? "Adapter worker failed without output", + parsed.errorCode ?? null, + ), + ); return; } @@ -108,14 +135,18 @@ function killAdapterProcessTree(pid: number | undefined, fallback: () => void): } } -function parseWorkerError(stdout: string): string | null { +function parseWorkerFailure( + stdout: string, +): { message: string; code: AdapterWorkerErrorCode | null } | null { if (!stdout.trim()) { return null; } try { const parsed = JSON.parse(stdout) as AdapterWorkerOutput; - return typeof parsed.error === "string" && parsed.error.length > 0 ? parsed.error : null; + return typeof parsed.error === "string" && parsed.error.length > 0 + ? { message: parsed.error, code: parsed.errorCode ?? null } + : null; } catch { return null; } diff --git a/src/platforms/core/state/integration-state.test.ts b/src/platforms/core/state/integration-state.test.ts index c7d2a074..82c9b1f4 100644 --- a/src/platforms/core/state/integration-state.test.ts +++ b/src/platforms/core/state/integration-state.test.ts @@ -15,6 +15,7 @@ import { startQrNativeAuthSession } from "../auth/qr-native.js"; import { refreshLocalIntegrationStates } from "./local-refresh.js"; import { completeAuthSession, + invalidateIntegrationAuth, markAuthSessionInProgress, removeIntegration, requestIntegrationAccess, @@ -497,6 +498,64 @@ process.exit(44); db.close(); }); + it("marks invalidated LinkedIn auth as needing auth and stops scheduled sync", () => { + const db = createDb(); + db.upsertIntegrationState({ + platform: "linkedin", + accountKey: "default", + displayName: "LinkedIn", + authState: "authenticated", + enabled: true, + connectionKind: "browser-session", + syncCapable: true, + launchStrategy: "chromium-auth", + launchTarget: "https://www.linkedin.com/login", + importedFrom: "local-cli", + metadata: { + authenticatedAt: 123, + keychainService: "so.cued.desktop.auth.linkedin", + keychainAccount: "default", + }, + }); + + const invalidated = invalidateIntegrationAuth(db, "linkedin", "default", { + errorSummary: "Authentication failed: 302 Found", + reason: "linkedin_auth_invalidated", + }); + + expect(invalidated).toMatchObject({ + authState: "needs_auth", + enabled: true, + syncCapable: false, + metadata: expect.objectContaining({ + authenticatedAt: null, + lastAuthError: "Authentication failed: 302 Found", + authInvalidationReason: "linkedin_auth_invalidated", + }), + }); + expect(db.listEnabledSyncTargets()).not.toContainEqual({ + platform: "linkedin", + account_key: "default", + }); + + const requested = requestIntegrationAccess(db, "linkedin", "default"); + const completed = completeAuthSession(db, requested.authSession.id, { + state: "authenticated", + keychainService: "so.cued.desktop.auth.linkedin", + keychainAccount: "default", + resultSummary: { provider: "linkedin" }, + }); + expect(completed.integration?.metadata).toEqual( + expect.objectContaining({ + authInvalidatedAt: null, + authInvalidationReason: null, + lastAuthError: null, + }), + ); + + db.close(); + }); + it("includes projection stats in integration status", () => { const db = createDb(); const requested = requestIntegrationAccess(db, "discord"); diff --git a/src/platforms/core/state/mutations.ts b/src/platforms/core/state/mutations.ts index 0a76c6d7..6b853fca 100644 --- a/src/platforms/core/state/mutations.ts +++ b/src/platforms/core/state/mutations.ts @@ -46,6 +46,36 @@ export function setIntegrationEnabled( return getIntegrationSummary(db, normalized, resolvedAccountKey); } +export function invalidateIntegrationAuth( + db: CuedDatabase, + platform: Platform, + accountKey: string, + input: { errorSummary: string; reason: string }, +): IntegrationStateSummary { + const integration = getIntegrationSummary(db, platform, accountKey); + db.upsertIntegrationState({ + platform, + accountKey, + displayName: integration.displayName, + authState: "needs_auth", + enabled: integration.enabled, + connectionKind: integration.connectionKind, + syncCapable: false, + launchStrategy: integration.launchStrategy, + launchTarget: integration.launchTarget, + importedFrom: integration.importedFrom, + artifactPaths: integration.artifactPaths, + metadata: { + ...(integration.metadata ?? {}), + authenticatedAt: null, + lastAuthError: input.errorSummary, + authInvalidatedAt: now(), + authInvalidationReason: input.reason, + }, + }); + return getIntegrationSummary(db, platform, accountKey); +} + function ensureRequestableIntegrationState( db: CuedDatabase, platform: string, @@ -366,6 +396,10 @@ export function completeAuthSession( authenticatedAt: input.state === "authenticated" ? now() : null, authResult: input.resultSummary ?? null, lastAuthError: input.errorSummary ?? null, + authInvalidatedAt: + input.state === "authenticated" ? null : (metadata.authInvalidatedAt ?? null), + authInvalidationReason: + input.state === "authenticated" ? null : (metadata.authInvalidationReason ?? null), blockedAt: input.state === "authenticated" ? null : (metadata.blockedAt ?? null), blockedReason: input.state === "authenticated" ? null : (metadata.blockedReason ?? null), ...(hideGeneratedPending diff --git a/src/platforms/core/sync.ts b/src/platforms/core/sync.ts index 2b971a3e..22bd97ba 100644 --- a/src/platforms/core/sync.ts +++ b/src/platforms/core/sync.ts @@ -35,8 +35,11 @@ export interface SyncBundle { diagnostics?: Record; } +export type AdapterWorkerErrorCode = "auth_invalid"; + export interface AdapterWorkerOutput { ok: boolean; bundle?: SyncBundle; error?: string; + errorCode?: AdapterWorkerErrorCode; } diff --git a/src/platforms/linkedin/api/client.ts b/src/platforms/linkedin/api/client.ts index 6696b6f1..bd4b4112 100644 --- a/src/platforms/linkedin/api/client.ts +++ b/src/platforms/linkedin/api/client.ts @@ -2,7 +2,7 @@ import { COOKIE_NAMES, DEFAULT_X_LI_TRACK, USER_AGENT } from "./constants.js"; import { getConnections } from "./contacts.js"; import { getConversations, getConversationsBefore } from "./conversations.js"; import { getReactors } from "./reactions.js"; -import { linkedInEncode, newGetRequest } from "./request.js"; +import { type LinkedInRequestOptions, linkedInEncode, newGetRequest } from "./request.js"; import type { Connection, Conversation, @@ -61,16 +61,13 @@ export class LinkedInClient { return this._userEntityURN; } - get userAgent(): string { - return this._userAgent; - } - - get xLiTrack(): string { - return this._xLiTrack; - } - - get pageInstance(): string { - return this._pageInstance; + get requestOptions(): LinkedInRequestOptions { + return { + userAgent: this._userAgent, + pageInstance: this._pageInstance, + xLiTrack: this._xLiTrack, + allowRedirects: false, + }; } isAuthenticated(): boolean { @@ -85,11 +82,11 @@ export class LinkedInClient { return this._userEntityURN; } - const response = await newGetRequest("https://www.linkedin.com/voyager/api/me", this._cookies, { - pageInstance: this._pageInstance, - xLiTrack: this._xLiTrack, - allowRedirects: false, - }) + const response = await newGetRequest( + "https://www.linkedin.com/voyager/api/me", + this._cookies, + this.requestOptions, + ) .withXLIHeaders() .doJSON<{ data?: { plainId?: number }; @@ -116,6 +113,16 @@ export class LinkedInClient { throw new Error("Could not determine user entity URN from /me response"); } + async verifySession(signal?: AbortSignal): Promise { + await newGetRequest("https://www.linkedin.com/voyager/api/me", this._cookies, { + ...this.requestOptions, + signal, + maxRetries: 0, + }) + .withXLIHeaders() + .doRaw(); + } + async getMailboxUrn(): Promise { return linkedInEncode(await this.fetchSelf()); } diff --git a/src/platforms/linkedin/api/contacts.ts b/src/platforms/linkedin/api/contacts.ts index b3633e27..010b575d 100644 --- a/src/platforms/linkedin/api/contacts.ts +++ b/src/platforms/linkedin/api/contacts.ts @@ -146,11 +146,7 @@ export async function getConnections( const response = await newGetRequest( `${API_URLS.connections}?${queryParams.toString()}`, client.cookies, - { - pageInstance: client.pageInstance, - xLiTrack: client.xLiTrack, - allowRedirects: false, - }, + client.requestOptions, ) .withHeader("Accept", CONTENT_TYPES.linkedInNormalized) .withXLIHeaders() diff --git a/src/platforms/linkedin/api/conversations.ts b/src/platforms/linkedin/api/conversations.ts index fcceb7d5..f96745ed 100644 --- a/src/platforms/linkedin/api/conversations.ts +++ b/src/platforms/linkedin/api/conversations.ts @@ -224,11 +224,12 @@ export async function getConversations( const mailboxUrn = await client.getMailboxUrn(); const queryId = syncToken ? "messengerConversationsBySyncToken" : "messengerConversations"; const variables: Record = syncToken ? { mailboxUrn, syncToken } : { mailboxUrn }; - const response = await newMessagingGraphQLRequest(client.cookies, queryId, variables, { - pageInstance: client.pageInstance, - xLiTrack: client.xLiTrack, - allowRedirects: false, - }).doJSON(); + const response = await newMessagingGraphQLRequest( + client.cookies, + queryId, + variables, + client.requestOptions, + ).doJSON(); return parseConversationsResponse(response); } @@ -247,11 +248,7 @@ export async function getConversationsBefore( client.cookies, "messengerConversationsByCursor", variables, - { - pageInstance: client.pageInstance, - xLiTrack: client.xLiTrack, - allowRedirects: false, - }, + client.requestOptions, ).doJSON(); return parseConversationsResponse(response); } @@ -271,11 +268,7 @@ export async function getConversationsWithCursor( client.cookies, "messengerConversationsByCursor", variables, - { - pageInstance: client.pageInstance, - xLiTrack: client.xLiTrack, - allowRedirects: false, - }, + client.requestOptions, ).doJSON(); return parseConversationsResponse(response); } diff --git a/src/platforms/linkedin/api/messages.ts b/src/platforms/linkedin/api/messages.ts index 9dfd98c9..e7c740f8 100644 --- a/src/platforms/linkedin/api/messages.ts +++ b/src/platforms/linkedin/api/messages.ts @@ -144,11 +144,7 @@ export async function getMessages( { conversationUrn: linkedInEncode(conversationURN), }, - { - pageInstance: client.pageInstance, - xLiTrack: client.xLiTrack, - allowRedirects: false, - }, + client.requestOptions, ).doJSON(); const data = getMessagesPayload(response); @@ -176,11 +172,7 @@ export async function getMessagesWithPrevCursor( count: String(PAGINATION_DEFAULTS.messagesCount), prevCursor: linkedInEncode(prevCursor), }, - { - pageInstance: client.pageInstance, - xLiTrack: client.xLiTrack, - allowRedirects: false, - }, + client.requestOptions, ).doJSON(); const data = getMessagesPayload(response); @@ -209,11 +201,7 @@ export async function getMessagesBefore( countBefore: String(PAGINATION_DEFAULTS.messagesCount), countAfter: "0", }, - { - pageInstance: client.pageInstance, - xLiTrack: client.xLiTrack, - allowRedirects: false, - }, + client.requestOptions, ).doJSON(); const data = getMessagesPayload(response); diff --git a/src/platforms/linkedin/api/reactions.ts b/src/platforms/linkedin/api/reactions.ts index 7dfd2126..5f1eec85 100644 --- a/src/platforms/linkedin/api/reactions.ts +++ b/src/platforms/linkedin/api/reactions.ts @@ -95,11 +95,7 @@ export async function getReactors( messageUrn: linkedInEncode(messageUrn), emoji: encodeURIComponent(emoji), }, - { - pageInstance: client.pageInstance, - xLiTrack: client.xLiTrack, - allowRedirects: false, - }, + client.requestOptions, ).doJSON(); return ( diff --git a/src/platforms/linkedin/api/request.test.ts b/src/platforms/linkedin/api/request.test.ts index c152955f..2b5067e0 100644 --- a/src/platforms/linkedin/api/request.test.ts +++ b/src/platforms/linkedin/api/request.test.ts @@ -60,6 +60,24 @@ describe("linkedin request auth invalidation", () => { ).rejects.toBeInstanceOf(LinkedInAuthError); }); + it("uses the captured browser user agent for authenticated requests", async () => { + const fetchMock = vi.fn(async () => new Response("{}", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await newGetRequest("https://www.linkedin.com/voyager/api/test", cookies, { + userAgent: "Cued LinkedIn Test/1.0", + }).doRaw(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://www.linkedin.com/voyager/api/test", + expect.objectContaining({ + headers: expect.objectContaining({ + "User-Agent": "Cued LinkedIn Test/1.0", + }), + }), + ); + }); + it("encodes tuple-style linkedin urn parentheses", () => { expect(linkedInEncode("urn:li:msg_conversation:(urn:li:fsd_profile:SELF123,CONV123)")).toBe( "urn%3Ali%3Amsg_conversation%3A%28urn%3Ali%3Afsd_profile%3ASELF123%2CCONV123%29", diff --git a/src/platforms/linkedin/api/request.ts b/src/platforms/linkedin/api/request.ts index b282dc16..b763c0b4 100644 --- a/src/platforms/linkedin/api/request.ts +++ b/src/platforms/linkedin/api/request.ts @@ -74,10 +74,13 @@ function invalidatesAuth(response: Response): boolean { type HttpMethod = "GET" | "POST"; -type RequestOptions = { +export type LinkedInRequestOptions = { + userAgent?: string; pageInstance?: string; xLiTrack?: string; allowRedirects?: boolean; + signal?: AbortSignal; + maxRetries?: number; }; class AuthedRequest { @@ -90,9 +93,12 @@ class AuthedRequest { constructor( private readonly url: string, cookies: Cookie[], - private readonly options: RequestOptions = {}, + private readonly options: LinkedInRequestOptions = {}, ) { Object.assign(this.headers, DEFAULT_HEADERS); + if (this.options.userAgent) { + this.headers["User-Agent"] = this.options.userAgent; + } this.headers.Cookie = formatCookieHeader(cookies); const csrfToken = getCSRFToken(cookies); if (csrfToken) { @@ -164,12 +170,14 @@ class AuthedRequest { async doRaw(): Promise { const url = this.buildUrl(); - for (let attempt = 0; attempt <= RETRY_CONFIG.maxRetries; attempt += 1) { + const maxRetries = this.options.maxRetries ?? RETRY_CONFIG.maxRetries; + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { const response = await fetch(url, { method: this.method, headers: this.headers, body: this.body ?? undefined, redirect: this.options.allowRedirects === false ? "manual" : "follow", + signal: this.options.signal, }); if ( @@ -183,7 +191,7 @@ class AuthedRequest { } if ((RETRY_CONFIG.retryableStatusCodes as readonly number[]).includes(response.status)) { - if (attempt < RETRY_CONFIG.maxRetries) { + if (attempt < maxRetries) { await sleep(withJitter(RETRY_CONFIG.baseDelayMs * 2 ** attempt)); continue; } @@ -236,7 +244,7 @@ export function withJitter(ms: number): number { export function newGetRequest( url: string, cookies: Cookie[], - options?: RequestOptions, + options?: LinkedInRequestOptions, ): AuthedRequest { return new AuthedRequest(url, cookies, options).withMethod("GET"); } @@ -244,7 +252,7 @@ export function newGetRequest( export function newPostRequest( url: string, cookies: Cookie[], - options?: RequestOptions, + options?: LinkedInRequestOptions, ): AuthedRequest { return new AuthedRequest(url, cookies, options).withMethod("POST"); } @@ -253,7 +261,7 @@ export function newMessagingGraphQLRequest( cookies: Cookie[], queryId: keyof typeof GRAPHQL_QUERY_IDS, variables: Record, - options?: RequestOptions, + options?: LinkedInRequestOptions, ): AuthedRequest { return newGetRequest(API_URLS.messagingGraphQL, cookies, options).withGraphQLQuery( queryId, diff --git a/src/platforms/linkedin/auth/keychain-import.test.ts b/src/platforms/linkedin/auth/keychain-import.test.ts new file mode 100644 index 00000000..5d9a7f96 --- /dev/null +++ b/src/platforms/linkedin/auth/keychain-import.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const loadKeychainSecretMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../core/secrets/keychain.js", () => ({ + authKeychainService: (platform: string) => `so.cued.desktop.auth.${platform}`, + legacyAuthKeychainService: (platform: string) => `so.cued.auth.${platform}`, + loadKeychainSecret: loadKeychainSecretMock, + storeKeychainSecret: vi.fn(), +})); + +import { validateLinkedInStoredAuth } from "./keychain-import.js"; + +const storedSession = { + cookies: [ + { name: "li_at", value: "token", domain: ".linkedin.com", path: "/" }, + { name: "JSESSIONID", value: '"ajax:123"', domain: ".linkedin.com", path: "/" }, + ], + userAgent: "Cued LinkedIn Test/1.0", + pageInstance: "urn:li:page:messaging_thread;test", + xLiTrack: '{"clientVersion":"1.0.0"}', +}; + +describe("LinkedIn stored auth validation", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + delete process.env.CUED_LINKEDIN_AUTH_VALIDATION_TIMEOUT_MS; + }); + + it("returns valid for a successful authenticated request", async () => { + loadKeychainSecretMock.mockReturnValue(storedSession); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("{}", { status: 200 })), + ); + + await expect(validateLinkedInStoredAuth("default")).resolves.toEqual({ status: "valid" }); + }); + + it("returns invalid only for an explicit LinkedIn auth failure", async () => { + loadKeychainSecretMock.mockReturnValue(storedSession); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(null, { + status: 302, + headers: { location: "https://www.linkedin.com/login" }, + }), + ), + ); + + await expect(validateLinkedInStoredAuth("default")).resolves.toEqual({ + status: "invalid", + }); + }); + + it("returns indeterminate for transient network failures", async () => { + loadKeychainSecretMock.mockReturnValue(storedSession); + vi.stubGlobal( + "fetch", + vi.fn(async () => Promise.reject(new Error("network unavailable"))), + ); + + await expect(validateLinkedInStoredAuth("default")).resolves.toEqual({ + status: "indeterminate", + errorSummary: "network unavailable", + }); + }); + + it("bounds validation timeouts without invalidating the session", async () => { + process.env.CUED_LINKEDIN_AUTH_VALIDATION_TIMEOUT_MS = "10"; + loadKeychainSecretMock.mockReturnValue(storedSession); + vi.stubGlobal( + "fetch", + vi.fn( + async (_url: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { + once: true, + }); + }), + ), + ); + + await expect(validateLinkedInStoredAuth("default")).resolves.toMatchObject({ + status: "indeterminate", + }); + }); + + it("does not retry rate limits or server failures during the bounded probe", async () => { + process.env.CUED_LINKEDIN_AUTH_VALIDATION_TIMEOUT_MS = "10"; + loadKeychainSecretMock.mockReturnValue(storedSession); + const fetchMock = vi.fn(async () => new Response(null, { status: 503 })); + vi.stubGlobal("fetch", fetchMock); + + await expect(validateLinkedInStoredAuth("default")).resolves.toMatchObject({ + status: "indeterminate", + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/platforms/linkedin/auth/keychain-import.ts b/src/platforms/linkedin/auth/keychain-import.ts index 1a3c49f7..5a67c2b8 100644 --- a/src/platforms/linkedin/auth/keychain-import.ts +++ b/src/platforms/linkedin/auth/keychain-import.ts @@ -9,11 +9,14 @@ import { } from "../../core/secrets/keychain.js"; import { completeAuthSession } from "../../core/state/mutations.js"; import { isUserRemovedIntegrationMetadata } from "../../core/state/status.js"; +import { LinkedInClient } from "../api/client.js"; +import { LinkedInAuthError } from "../api/request.js"; import { type LinkedInSessionSecret, parseLinkedInSessionSecret } from "./session-store.js"; const LINKEDIN_KEYCHAIN_SERVICE = authKeychainService("linkedin"); const LEGACY_LINKEDIN_KEYCHAIN_SERVICE = legacyAuthKeychainService("linkedin"); const LINKEDIN_DEFAULT_ACCOUNT_KEY = "default"; +const DEFAULT_LINKEDIN_AUTH_VALIDATION_TIMEOUT_MS = 5_000; export interface ImportedLinkedInSessionResult { platform: "linkedin"; @@ -21,6 +24,11 @@ export interface ImportedLinkedInSessionResult { imported: boolean; } +export type LinkedInStoredAuthValidation = + | { status: "valid" } + | { status: "invalid" } + | { status: "indeterminate"; errorSummary: string }; + function readLinkedInKeychainSecret(accountKey: string): Record | null { const current = loadKeychainSecret(LINKEDIN_KEYCHAIN_SERVICE, accountKey); if (current) { @@ -45,6 +53,46 @@ function hasRequiredCookies(cookieNames: readonly string[]): boolean { return cookieNamesSet.has("li_at") && cookieNamesSet.has("JSESSIONID"); } +function getLinkedInAuthValidationTimeoutMs(): number { + const configured = Number(process.env.CUED_LINKEDIN_AUTH_VALIDATION_TIMEOUT_MS); + return Number.isFinite(configured) && configured > 0 + ? Math.trunc(configured) + : DEFAULT_LINKEDIN_AUTH_VALIDATION_TIMEOUT_MS; +} + +export async function validateLinkedInStoredAuth( + accountKey: string, +): Promise { + const secret = readLinkedInKeychainSecret(accountKey); + if (!secret) { + return { status: "invalid" }; + } + + const session = parseLinkedInSessionSecret(secret); + if (!hasRequiredCookies(getCookieNames(session))) { + return { status: "invalid" }; + } + + const client = new LinkedInClient({ + cookies: session.cookies, + userAgent: session.userAgent ?? undefined, + pageInstance: session.pageInstance ?? undefined, + xLiTrack: session.xLiTrack ?? undefined, + }); + try { + await client.verifySession(AbortSignal.timeout(getLinkedInAuthValidationTimeoutMs())); + return { status: "valid" }; + } catch (error) { + if (error instanceof LinkedInAuthError) { + return { status: "invalid" }; + } + return { + status: "indeterminate", + errorSummary: error instanceof Error ? error.message : String(error), + }; + } +} + export function importLinkedInStoredAuth( db: CuedDatabase, options: { reviveUserRemoved?: boolean } = {}, diff --git a/src/platforms/linkedin/auth/session-store.test.ts b/src/platforms/linkedin/auth/session-store.test.ts new file mode 100644 index 00000000..e1cebcd7 --- /dev/null +++ b/src/platforms/linkedin/auth/session-store.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { parseLinkedInSessionSecret } from "./session-store.js"; + +describe("LinkedIn session storage", () => { + it("preserves the browser user agent with the captured session", () => { + expect( + parseLinkedInSessionSecret({ + cookies: [], + userAgent: "Mozilla/5.0 Chrome/150.0.0.0", + }).userAgent, + ).toBe("Mozilla/5.0 Chrome/150.0.0.0"); + }); +}); diff --git a/src/platforms/linkedin/auth/session-store.ts b/src/platforms/linkedin/auth/session-store.ts index c3e66426..51670192 100644 --- a/src/platforms/linkedin/auth/session-store.ts +++ b/src/platforms/linkedin/auth/session-store.ts @@ -3,6 +3,7 @@ import type { Cookie } from "../api/types.js"; export interface LinkedInSessionSecret { cookies: Cookie[]; + userAgent: string | null; pageInstance: string | null; xLiTrack: string | null; serviceVersion: string | null; @@ -19,6 +20,7 @@ export function parseLinkedInSessionSecret(secret: Record): Lin const cookies = Array.isArray(secret.cookies) ? (secret.cookies as Cookie[]) : []; return { cookies, + userAgent: asString(secret.userAgent), pageInstance: asString(secret.pageInstance), xLiTrack: asString(secret.xLiTrack), serviceVersion: asString(secret.serviceVersion), diff --git a/src/platforms/linkedin/realtime/session.test.ts b/src/platforms/linkedin/realtime/session.test.ts index d8421d44..46af6efa 100644 --- a/src/platforms/linkedin/realtime/session.test.ts +++ b/src/platforms/linkedin/realtime/session.test.ts @@ -7,13 +7,14 @@ type TestableRealtimeSession = { connectOnce(signal: AbortSignal): Promise; }; -function createSession() { +function createSession(userAgent?: string) { const session = new LinkedInRealtimeSession({ accountKey: "default", cookies: [ { name: "li_at", value: "token", domain: ".linkedin.com", path: "/" }, { name: "JSESSIONID", value: '"ajax:123"', domain: ".linkedin.com", path: "/" }, ], + userAgent, pageInstance: "urn:li:page:d_flagship3_messaging_conversation_detail;test", xLiTrack: '{"clientVersion":"1.0.0"}', realtimeQueryMap: "{}", @@ -88,6 +89,37 @@ describe("LinkedInRealtimeSession", () => { expect(unhandledRejection).not.toHaveBeenCalled(); }); + + it("uses the captured browser user agent for realtime requests", async () => { + const { internals } = createSession("Cued LinkedIn Realtime Test/1.0"); + internals.runHeartbeatLoop = vi.fn( + (signal: AbortSignal) => + new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }), + ); + const fetchMock = vi.fn(async () => { + const stream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + return new Response(stream, { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(internals.connectOnce(new AbortController().signal)).rejects.toThrow( + "LinkedIn realtime stream closed", + ); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/realtime/connect"), + expect.objectContaining({ + headers: expect.objectContaining({ + "User-Agent": "Cued LinkedIn Realtime Test/1.0", + }), + }), + ); + }); }); describe("LinkedInRealtimeSupervisor", () => { diff --git a/src/platforms/linkedin/realtime/session.ts b/src/platforms/linkedin/realtime/session.ts index 5b0000d5..8971ea52 100644 --- a/src/platforms/linkedin/realtime/session.ts +++ b/src/platforms/linkedin/realtime/session.ts @@ -28,6 +28,7 @@ export interface LinkedInRealtimeStatus { interface LinkedInRealtimeSessionOptions { accountKey: string; cookies: Cookie[]; + userAgent?: string | null; pageInstance: string; xLiTrack: string; serviceVersion?: string | null; @@ -51,6 +52,7 @@ export interface LinkedInRealtimeSessionLike { export interface LinkedInRealtimeSupervisorSessionInput { accountKey: string; cookies: Cookie[]; + userAgent?: string | null; pageInstance: string; xLiTrack: string; serviceVersion?: string | null; @@ -128,11 +130,11 @@ function wait(ms: number, signal?: AbortSignal): Promise { }); } -function baseHeaders(cookies: Cookie[]): Record { +function baseHeaders(cookies: Cookie[], userAgent: string): Record { return { ...DEFAULT_HEADERS, Accept: CONTENT_TYPES.linkedInNormalized, - "User-Agent": USER_AGENT, + "User-Agent": userAgent, Cookie: cookieHeader(cookies), }; } @@ -179,6 +181,7 @@ function makeStatus(input: { export class LinkedInRealtimeSession implements LinkedInRealtimeSessionLike { private readonly accountKey: string; private readonly cookies: Cookie[]; + private readonly userAgent: string; private readonly pageInstance: string; private readonly xLiTrack: string; private readonly serviceVersion: string; @@ -205,6 +208,7 @@ export class LinkedInRealtimeSession implements LinkedInRealtimeSessionLike { constructor(options: LinkedInRealtimeSessionOptions) { this.accountKey = options.accountKey; this.cookies = options.cookies; + this.userAgent = options.userAgent ?? USER_AGENT; this.pageInstance = options.pageInstance; this.xLiTrack = options.xLiTrack; this.serviceVersion = parseServiceVersion(options.serviceVersion ?? null, options.xLiTrack); @@ -301,6 +305,7 @@ export class LinkedInRealtimeSession implements LinkedInRealtimeSessionLike { } const client = new LinkedInClient({ cookies: this.cookies, + userAgent: this.userAgent, pageInstance: this.pageInstance, xLiTrack: this.xLiTrack, }); @@ -311,7 +316,7 @@ export class LinkedInRealtimeSession implements LinkedInRealtimeSessionLike { const response = await fetch(`${API_URLS.realtimeConnect}?rc=1`, { method: "GET", headers: { - ...baseHeaders(this.cookies), + ...baseHeaders(this.cookies, this.userAgent), Accept: "text/event-stream", Referer: `${API_URLS.messagingBase}/`, "csrf-token": getCsrfToken(this.cookies) ?? "", @@ -379,7 +384,7 @@ export class LinkedInRealtimeSession implements LinkedInRealtimeSessionLike { const response = await fetch(`${API_URLS.realtimeHeartbeat}?action=sendHeartbeat`, { method: "POST", headers: { - ...baseHeaders(this.cookies), + ...baseHeaders(this.cookies, this.userAgent), Accept: "*/*", Origin: "https://www.linkedin.com", Priority: "u=1, i", @@ -554,6 +559,7 @@ function fingerprintSessionInput(input: LinkedInRealtimeSupervisorSessionInput): secure: cookie.secure ?? null, sameSite: cookie.sameSite ?? null, })), + userAgent: input.userAgent ?? null, pageInstance: input.pageInstance, xLiTrack: input.xLiTrack, serviceVersion: input.serviceVersion ?? null, diff --git a/src/platforms/linkedin/sync/bundle.ts b/src/platforms/linkedin/sync/bundle.ts index 6ade2321..11aee005 100644 --- a/src/platforms/linkedin/sync/bundle.ts +++ b/src/platforms/linkedin/sync/bundle.ts @@ -860,6 +860,7 @@ export async function buildLinkedInSyncBundle(options?: { options?.client ?? new LinkedInClient({ cookies: loadLinkedInCookies(accountKey), + userAgent: session?.userAgent ?? undefined, pageInstance: session?.pageInstance ?? undefined, xLiTrack: session?.xLiTrack ?? undefined, }); diff --git a/src/platforms/linkedin/sync/worker.ts b/src/platforms/linkedin/sync/worker.ts index c4e28c96..50c6db90 100644 --- a/src/platforms/linkedin/sync/worker.ts +++ b/src/platforms/linkedin/sync/worker.ts @@ -1,4 +1,5 @@ import { readAdapterInvocationEnv } from "../../core/invocation.js"; +import { LinkedInAuthError } from "../api/request.js"; import { buildLinkedInSyncBundle } from "./bundle.js"; async function main(): Promise { @@ -20,6 +21,7 @@ async function main(): Promise { JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error), + ...(error instanceof LinkedInAuthError ? { errorCode: "auth_invalid" as const } : {}), }), ); process.exitCode = 1; diff --git a/src/runtime/daemon/server.test.ts b/src/runtime/daemon/server.test.ts index 192a2a98..bc723db6 100644 --- a/src/runtime/daemon/server.test.ts +++ b/src/runtime/daemon/server.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { AdapterWorkerError } from "../../platforms/core/runner.js"; import { buildSyncResumeTargets, buildWhatsAppRealtimeSnapshotFromEvent, @@ -7,6 +8,7 @@ import { getAutoSyncTargets, InteractiveAuthSessions, isDisconnectedSocketError, + isLinkedInAdapterAuthInvalidation, requestWhatsAppHistoryBackfillOnce, shouldDeferContinuationProjection, shouldDrainOutboundQueue, @@ -14,6 +16,32 @@ import { shouldSkipConnectedDiscordSchedulerSync, } from "./server.js"; +describe("LinkedIn auth invalidation", () => { + it("recognizes typed LinkedIn auth failures from adapter workers", () => { + expect( + isLinkedInAdapterAuthInvalidation( + "linkedin", + new AdapterWorkerError("Authentication failed: 302 Found", "auth_invalid"), + ), + ).toBe(true); + }); + + it("does not invalidate auth for transient or unrelated adapter failures", () => { + expect( + isLinkedInAdapterAuthInvalidation( + "linkedin", + new AdapterWorkerError("LinkedIn rate limited"), + ), + ).toBe(false); + expect( + isLinkedInAdapterAuthInvalidation( + "slack", + new AdapterWorkerError("Authentication failed", "auth_invalid"), + ), + ).toBe(false); + }); +}); + describe("discord scheduler pacing", () => { const connectedStatus = { platform: "discord" as const, diff --git a/src/runtime/daemon/server.ts b/src/runtime/daemon/server.ts index 28286d63..a0a35fe0 100644 --- a/src/runtime/daemon/server.ts +++ b/src/runtime/daemon/server.ts @@ -29,9 +29,10 @@ import { selectAdapterInvocationProofs, } from "../../platforms/core/invocation.js"; import { isAdapterPlatform, listAutoSyncPlatforms } from "../../platforms/core/registry.js"; -import { runAdapter } from "../../platforms/core/runner.js"; +import { isAdapterWorkerError, runAdapter } from "../../platforms/core/runner.js"; import { loadIntegrationSecret } from "../../platforms/core/secrets/keychain.js"; import { refreshLocalIntegrationStates } from "../../platforms/core/state/local-refresh.js"; +import { invalidateIntegrationAuth } from "../../platforms/core/state/mutations.js"; import { refreshManagedIntegrationStates } from "../../platforms/core/state/refresh.js"; import { getIntegrationSummary } from "../../platforms/core/state/status.js"; import type { SyncContinuation } from "../../platforms/core/sync.js"; @@ -344,6 +345,7 @@ type WhatsAppDesiredSession = { type LinkedInDesiredSession = { accountKey: string; cookies: ReturnType["cookies"]; + userAgent: string | null; pageInstance: string; xLiTrack: string; serviceVersion: string | null; @@ -522,6 +524,13 @@ export function shouldSkipConnectedDiscordSchedulerSync( return targetPlatform === "discord" && trigger === "scheduler" && status?.state === "connected"; } +export function isLinkedInAdapterAuthInvalidation( + platform: string | null, + error: unknown, +): boolean { + return platform === "linkedin" && isAdapterWorkerError(error, "auth_invalid"); +} + export function shouldDrainOutboundQueue(input: { outboundSendEnabled: boolean; isUpdateShutdownRequested: boolean; @@ -1813,6 +1822,7 @@ async function collectDesiredLinkedInSessions(db: ReturnType { ); requestDiscordRealtimeReconcile(); } + if (isLinkedInAdapterAuthInvalidation(currentRun.platform, error)) { + invalidateIntegrationAuth( + db, + "linkedin", + currentRun.account_key ?? getDefaultAccountKeyForPlatform("linkedin"), + { + errorSummary: errorMessage, + reason: "linkedin_auth_invalidated", + }, + ); + requestLinkedInRealtimeReconcile(); + requestMenuBarStatusWrite("linkedin_auth_invalidated"); + } db.failRun(currentRun.id, errorMessage, undefined, null, runClaim); if (currentRun.platform === "signal") { requestSignalRealtimeReconcile();