Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/linkedin-confidential-oauth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/sdk": patch
---

Support LinkedIn confidential authorization-code connections by omitting PKCE parameters from its standard web flow while preserving PKCE for public and native clients.
51 changes: 51 additions & 0 deletions packages/core/sdk/src/oauth-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,57 @@ const routeTokenEndpointToLoopback = (
};

describe("oauth.start / oauth.complete", () => {
it.effect("omits PKCE only for LinkedIn confidential web clients", () =>
Effect.scoped(
Effect.gen(function* () {
const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
yield* executor.acme.seed();
const linkedinAuthorizationUrl = "https://www.linkedin.com/oauth/v2/authorization";

const startFor = (slug: string, clientSecret: string, name: string) =>
Effect.gen(function* () {
const client = OAuthClientSlug.make(slug);
yield* executor.oauth.createClient({
owner: "org",
slug: client,
authorizationUrl: linkedinAuthorizationUrl,
tokenUrl: "https://www.linkedin.com/oauth/v2/accessToken",
grant: "authorization_code",
clientId: `${slug}-id`,
clientSecret,
});
const started = yield* executor.oauth.start({
owner: "org",
client,
clientOwner: "org",
name: ConnectionName.make(name),
integration: INTEG,
template: TEMPLATE,
});
if (started.status !== "redirect") {
return yield* Effect.die("expected a redirect-status OAuth start");
}
const session = yield* Effect.promise(() =>
config.db.findFirst("oauth_session", {
where: (b) => b("state", "=", String(started.state)),
}),
);
return { url: new URL(started.authorizationUrl), session };
});

const confidential = yield* startFor("linkedin-confidential", "secret", "confidential");
expect(confidential.url.searchParams.has("code_challenge")).toBe(false);
expect(confidential.url.searchParams.has("code_challenge_method")).toBe(false);
expect(confidential.session?.pkce_verifier).toBeNull();

const publicClient = yield* startFor("linkedin-public", "", "public");
expect(publicClient.url.searchParams.get("code_challenge_method")).toBe("S256");
expect(publicClient.url.searchParams.get("code_challenge")).toEqual(expect.any(String));
expect(publicClient.session?.pkce_verifier).toEqual(expect.any(String));
}),
),
);

it.effect(
"createClient → start (redirect) → complete mints a connection + tools, executable",
() =>
Expand Down
51 changes: 51 additions & 0 deletions packages/core/sdk/src/oauth-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
optionalScopesFromAuthorizationUrl,
refreshAccessToken,
shouldRefreshToken,
shouldUsePkce,
} from "./oauth-helpers";
import { serveTestHttpApp } from "./testing";

Expand Down Expand Up @@ -225,6 +226,22 @@ describe("providerAuthorizeExtras (provider authorization quirks)", () => {
});
});

describe("shouldUsePkce", () => {
it("disables PKCE only for confidential clients on LinkedIn's standard web endpoint", () => {
const linkedin = "https://www.linkedin.com/oauth/v2/authorization";
expect(shouldUsePkce(linkedin, "client-secret")).toBe(false);
expect(shouldUsePkce(linkedin, "")).toBe(true);
expect(
shouldUsePkce("https://www.linkedin.com/oauth/native-pkce/authorization", "secret"),
).toBe(true);
expect(shouldUsePkce("http://www.linkedin.com/oauth/v2/authorization", "secret")).toBe(true);
expect(shouldUsePkce("https://www.linkedin.com:8443/oauth/v2/authorization", "secret")).toBe(
true,
);
expect(shouldUsePkce("https://accounts.google.com/o/oauth2/v2/auth", "secret")).toBe(true);
});
});

describe("buildAuthorizationUrl", () => {
const baseInput = {
authorizationUrl: "https://example.com/authorize",
Expand All @@ -249,6 +266,23 @@ describe("buildAuthorizationUrl", () => {
);
});

it("omits PKCE params when no challenge is supplied", () => {
const { codeChallenge: _, ...withoutPkce } = baseInput;
const url = new URL(
buildAuthorizationUrl({
...withoutPkce,
authorizationUrl:
"https://example.com/authorize?code_challenge=stale&code_challenge_method=S256",
extraParams: {
code_challenge: "also-stale",
code_challenge_method: "plain",
},
}),
);
expect(url.searchParams.has("code_challenge_method")).toBe(false);
expect(url.searchParams.has("code_challenge")).toBe(false);
});

it("supports a custom scope separator (e.g. comma for legacy providers)", () => {
const url = new URL(buildAuthorizationUrl({ ...baseInput, scopeSeparator: "," }));
expect(url.searchParams.get("scope")).toBe("read,write");
Expand Down Expand Up @@ -329,6 +363,23 @@ describe("buildAuthorizationUrl", () => {
});

describe("exchangeAuthorizationCode", () => {
it.effect("omits the PKCE verifier for a confidential flow that does not use PKCE", () =>
withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
Effect.gen(function* () {
yield* exchangeAuthorizationCode({
tokenUrl,
clientId: "cid",
clientSecret: "csecret",
redirectUrl: "https://app.example.com/cb",
code: "abc",
});
const call = (yield* calls)[0]!;
expect(call.body.get("client_secret")).toBe("csecret");
expect(call.body.has("code_verifier")).toBe(false);
}),
),
);

it.effect("supports JSON token exchange with HTTP Basic client authentication", () =>
withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
Effect.gen(function* () {
Expand Down
34 changes: 29 additions & 5 deletions packages/core/sdk/src/oauth-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,20 @@ export const createPkceCodeChallenge = (verifier: string): Promise<string> =>
* and redeemed by `oauth.complete`. */
export const createOAuthState = (): string => oauth.generateRandomState();

/** LinkedIn's standard confidential web flow rejects token requests that
* include PKCE material. Its separate native endpoint supports PKCE, so keep
* the exception tied to the documented web authorization endpoint and only
* apply it when the client has a secret. */
export const shouldUsePkce = (authorizationUrl: string, clientSecret?: string | null): boolean => {
if (!clientSecret) return true;
if (!URL.canParse(authorizationUrl)) return true;
const url = new URL(authorizationUrl);
return !(
url.origin.toLowerCase() === "https://www.linkedin.com" &&
url.pathname === "/oauth/v2/authorization"
);
};

// ---------------------------------------------------------------------------
// Authorization URL builder
// ---------------------------------------------------------------------------
Expand All @@ -202,7 +216,7 @@ export type BuildAuthorizationUrlInput = {
readonly scopes: readonly string[];
readonly state: string;
/** Pre-computed base64url S256 challenge (from `createPkceCodeChallenge`). */
readonly codeChallenge: string;
readonly codeChallenge?: string;
/** Separator between scopes. RFC 6749 says space; some providers use comma. */
readonly scopeSeparator?: string;
/** RFC 8707 Resource Indicator. MCP Authorization 2025-06-18 §"Resource
Expand Down Expand Up @@ -235,8 +249,10 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string
url.searchParams.set("scope", input.scopes.join(separator));
}
url.searchParams.set("state", input.state);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("code_challenge", input.codeChallenge);
if (input.codeChallenge) {
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("code_challenge", input.codeChallenge);
}
if (input.resource) {
url.searchParams.set("resource", input.resource);
}
Expand All @@ -245,6 +261,12 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string
url.searchParams.set(k, v);
}
}
// When this flow does not use PKCE, configured endpoint or provider-extra
// parameters must not reintroduce a challenge without a persisted verifier.
if (!input.codeChallenge) {
url.searchParams.delete("code_challenge_method");
url.searchParams.delete("code_challenge");
}
return url.toString();
};

Expand Down Expand Up @@ -1213,7 +1235,7 @@ export type ExchangeAuthorizationCodeInput = {
readonly clientId: string;
readonly clientSecret?: string | null;
readonly redirectUrl: string;
readonly codeVerifier: string;
readonly codeVerifier?: string;
readonly code: string;
readonly clientAuth?: ClientAuthMethod;
/** Encoding required by the provider's token endpoint. OAuth defaults to
Expand Down Expand Up @@ -1300,8 +1322,10 @@ export const exchangeAuthorizationCode = (
const params = new URLSearchParams({
code: input.code,
redirect_uri: input.redirectUrl,
code_verifier: input.codeVerifier,
});
if (input.codeVerifier) {
params.set("code_verifier", input.codeVerifier);
}
if (input.resource) {
params.set("resource", input.resource);
}
Expand Down
25 changes: 16 additions & 9 deletions packages/core/sdk/src/oauth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import {
exchangeClientCredentials,
isLoopbackHttpUrl,
rebindTokenEndpointHostToCallbackDomain,
shouldUsePkce,
type OAuth2TokenResponse,
type OAuthEndpointUrlPolicy,
} from "./oauth-helpers";
Expand Down Expand Up @@ -2048,9 +2049,14 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
...workspaceOptionalScopes,
]);

// authorization_code: persist a session + build the authorize URL.
const verifier = createPkceCodeVerifier();
const challenge = yield* Effect.promise(() => createPkceCodeChallenge(verifier));
// LinkedIn's standard confidential web flow rejects PKCE parameters. Its
// native/public flow and every other provider continue to require PKCE.
const usePkce = shouldUsePkce(client.authorizationUrl, client.clientSecret);
const verifier = usePkce ? createPkceCodeVerifier() : null;
const challenge =
verifier === null
? undefined
: yield* Effect.promise(() => createPkceCodeChallenge(verifier));
const state = OAuthState.make(createOAuthState());
const providerState = encodeOAuthCallbackState({
state: String(state),
Expand Down Expand Up @@ -2230,11 +2236,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
}
}

// The PKCE verifier is minted by `start` for every authorization_code
// session. A null/missing one means a corrupt session row — exchanging
// with an empty verifier would violate RFC 7636 and the AS would reject
// it with an opaque error. Fail loudly + require a restart instead.
if (session.pkceVerifier == null) {
const requiresPkce = shouldUsePkce(client.authorizationUrl, client.clientSecret);
// Every authorization-code flow except LinkedIn's confidential web flow
// requires the verifier minted by `start`. Missing one is a corrupt row.
if (requiresPkce && session.pkceVerifier == null) {
return yield* new OAuthCompleteError({
message: `OAuth session ${input.state} is missing its PKCE code verifier; restart the flow.`,
restartRequired: true,
Expand All @@ -2256,7 +2261,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
clientId: client.clientId,
clientSecret: client.clientSecret,
redirectUrl: session.redirectUrl,
codeVerifier: session.pkceVerifier,
// The persisted verifier records the request that actually started.
// Keep using it if client settings change while that request is open.
codeVerifier: session.pkceVerifier ?? undefined,
code: input.code,
clientAuth: client.tokenEndpointAuthMethod,
requestFormat: client.tokenRequestFormat,
Expand Down
Loading