From 916dd2ad699074e508e3dab13d7fb134962e7a92 Mon Sep 17 00:00:00 2001 From: baggiiiie Date: Tue, 22 Sep 2026 16:44:27 +0000 Subject: [PATCH 1/2] Fix LinkedIn confidential OAuth flow --- .changeset/linkedin-confidential-oauth.md | 5 ++ packages/core/sdk/src/oauth-flow.test.ts | 51 +++++++++++++++++++++ packages/core/sdk/src/oauth-helpers.test.ts | 47 +++++++++++++++++++ packages/core/sdk/src/oauth-helpers.ts | 31 +++++++++++-- packages/core/sdk/src/oauth-service.ts | 23 ++++++---- 5 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 .changeset/linkedin-confidential-oauth.md diff --git a/.changeset/linkedin-confidential-oauth.md b/.changeset/linkedin-confidential-oauth.md new file mode 100644 index 0000000000..ec2564719f --- /dev/null +++ b/.changeset/linkedin-confidential-oauth.md @@ -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. diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts index b98d500716..eef617ac65 100644 --- a/packages/core/sdk/src/oauth-flow.test.ts +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -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", () => diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index 92bc623d06..c9d9a4325e 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -28,6 +28,7 @@ import { optionalScopesFromAuthorizationUrl, refreshAccessToken, shouldRefreshToken, + shouldUsePkce, } from "./oauth-helpers"; import { serveTestHttpApp } from "./testing"; @@ -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", @@ -249,6 +266,19 @@ 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", + }), + ); + 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"); @@ -329,6 +359,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* () { diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index b8b11714c2..143e57b182 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -191,6 +191,20 @@ export const createPkceCodeChallenge = (verifier: string): Promise => * 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 // --------------------------------------------------------------------------- @@ -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 @@ -235,8 +249,13 @@ 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); + } else { + url.searchParams.delete("code_challenge_method"); + url.searchParams.delete("code_challenge"); + } if (input.resource) { url.searchParams.set("resource", input.resource); } @@ -1213,7 +1232,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 @@ -1300,8 +1319,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); } diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 7d8d7d46ea..d78222bca4 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -106,6 +106,7 @@ import { exchangeClientCredentials, isLoopbackHttpUrl, rebindTokenEndpointHostToCallbackDomain, + shouldUsePkce, type OAuth2TokenResponse, type OAuthEndpointUrlPolicy, } from "./oauth-helpers"; @@ -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), @@ -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 usePkce = 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 (usePkce && session.pkceVerifier == null) { return yield* new OAuthCompleteError({ message: `OAuth session ${input.state} is missing its PKCE code verifier; restart the flow.`, restartRequired: true, @@ -2256,7 +2261,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { clientId: client.clientId, clientSecret: client.clientSecret, redirectUrl: session.redirectUrl, - codeVerifier: session.pkceVerifier, + codeVerifier: usePkce ? (session.pkceVerifier ?? undefined) : undefined, code: input.code, clientAuth: client.tokenEndpointAuthMethod, requestFormat: client.tokenRequestFormat, From dc9111e46707abb751a9e85626ef23b130769db5 Mon Sep 17 00:00:00 2001 From: baggiiiie Date: Wed, 23 Sep 2026 03:24:53 +0000 Subject: [PATCH 2/2] Harden OAuth PKCE session consistency --- packages/core/sdk/src/oauth-helpers.test.ts | 4 ++++ packages/core/sdk/src/oauth-helpers.ts | 9 ++++++--- packages/core/sdk/src/oauth-service.ts | 8 +++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index c9d9a4325e..292ba09ba7 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -273,6 +273,10 @@ describe("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); diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index 143e57b182..aec3cf77e8 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -252,9 +252,6 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string if (input.codeChallenge) { url.searchParams.set("code_challenge_method", "S256"); url.searchParams.set("code_challenge", input.codeChallenge); - } else { - url.searchParams.delete("code_challenge_method"); - url.searchParams.delete("code_challenge"); } if (input.resource) { url.searchParams.set("resource", input.resource); @@ -264,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(); }; diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index d78222bca4..00e9f06e98 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -2236,10 +2236,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { } } - const usePkce = shouldUsePkce(client.authorizationUrl, client.clientSecret); + 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 (usePkce && session.pkceVerifier == null) { + 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, @@ -2261,7 +2261,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { clientId: client.clientId, clientSecret: client.clientSecret, redirectUrl: session.redirectUrl, - codeVerifier: usePkce ? (session.pkceVerifier ?? undefined) : undefined, + // 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,