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
7 changes: 7 additions & 0 deletions src/platforms/core/auth/chromium-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 &&
Expand All @@ -413,6 +418,7 @@ async function extractLinkedInAuth(
secure: cookie.secure,
sameSite: cookie.sameSite,
})),
userAgent,
pageInstance: captured.pageInstance,
xLiTrack: captured.xLiTrack,
serviceVersion: captured.serviceVersion,
Expand All @@ -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),
Expand Down
80 changes: 79 additions & 1 deletion src/platforms/core/auth/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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", () => ({
Expand Down Expand Up @@ -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");
});
Expand Down Expand Up @@ -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({
Expand Down
16 changes: 15 additions & 1 deletion src/platforms/core/auth/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>)
Expand Down
27 changes: 26 additions & 1 deletion src/platforms/core/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
45 changes: 38 additions & 7 deletions src/platforms/core/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand All @@ -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;
}
Expand Down
59 changes: 59 additions & 0 deletions src/platforms/core/state/integration-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { startQrNativeAuthSession } from "../auth/qr-native.js";
import { refreshLocalIntegrationStates } from "./local-refresh.js";
import {
completeAuthSession,
invalidateIntegrationAuth,
markAuthSessionInProgress,
removeIntegration,
requestIntegrationAccess,
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading