Skip to content

Commit 916dd2a

Browse files
committed
Fix LinkedIn confidential OAuth flow
1 parent d27e673 commit 916dd2a

5 files changed

Lines changed: 143 additions & 14 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@executor-js/sdk": patch
3+
---
4+
5+
Support LinkedIn confidential authorization-code connections by omitting PKCE parameters from its standard web flow while preserving PKCE for public and native clients.

‎packages/core/sdk/src/oauth-flow.test.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,57 @@ const routeTokenEndpointToLoopback = (
191191
};
192192

193193
describe("oauth.start / oauth.complete", () => {
194+
it.effect("omits PKCE only for LinkedIn confidential web clients", () =>
195+
Effect.scoped(
196+
Effect.gen(function* () {
197+
const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
198+
yield* executor.acme.seed();
199+
const linkedinAuthorizationUrl = "https://www.linkedin.com/oauth/v2/authorization";
200+
201+
const startFor = (slug: string, clientSecret: string, name: string) =>
202+
Effect.gen(function* () {
203+
const client = OAuthClientSlug.make(slug);
204+
yield* executor.oauth.createClient({
205+
owner: "org",
206+
slug: client,
207+
authorizationUrl: linkedinAuthorizationUrl,
208+
tokenUrl: "https://www.linkedin.com/oauth/v2/accessToken",
209+
grant: "authorization_code",
210+
clientId: `${slug}-id`,
211+
clientSecret,
212+
});
213+
const started = yield* executor.oauth.start({
214+
owner: "org",
215+
client,
216+
clientOwner: "org",
217+
name: ConnectionName.make(name),
218+
integration: INTEG,
219+
template: TEMPLATE,
220+
});
221+
if (started.status !== "redirect") {
222+
return yield* Effect.die("expected a redirect-status OAuth start");
223+
}
224+
const session = yield* Effect.promise(() =>
225+
config.db.findFirst("oauth_session", {
226+
where: (b) => b("state", "=", String(started.state)),
227+
}),
228+
);
229+
return { url: new URL(started.authorizationUrl), session };
230+
});
231+
232+
const confidential = yield* startFor("linkedin-confidential", "secret", "confidential");
233+
expect(confidential.url.searchParams.has("code_challenge")).toBe(false);
234+
expect(confidential.url.searchParams.has("code_challenge_method")).toBe(false);
235+
expect(confidential.session?.pkce_verifier).toBeNull();
236+
237+
const publicClient = yield* startFor("linkedin-public", "", "public");
238+
expect(publicClient.url.searchParams.get("code_challenge_method")).toBe("S256");
239+
expect(publicClient.url.searchParams.get("code_challenge")).toEqual(expect.any(String));
240+
expect(publicClient.session?.pkce_verifier).toEqual(expect.any(String));
241+
}),
242+
),
243+
);
244+
194245
it.effect(
195246
"createClient → start (redirect) → complete mints a connection + tools, executable",
196247
() =>

‎packages/core/sdk/src/oauth-helpers.test.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
optionalScopesFromAuthorizationUrl,
2929
refreshAccessToken,
3030
shouldRefreshToken,
31+
shouldUsePkce,
3132
} from "./oauth-helpers";
3233
import { serveTestHttpApp } from "./testing";
3334

@@ -225,6 +226,22 @@ describe("providerAuthorizeExtras (provider authorization quirks)", () => {
225226
});
226227
});
227228

229+
describe("shouldUsePkce", () => {
230+
it("disables PKCE only for confidential clients on LinkedIn's standard web endpoint", () => {
231+
const linkedin = "https://www.linkedin.com/oauth/v2/authorization";
232+
expect(shouldUsePkce(linkedin, "client-secret")).toBe(false);
233+
expect(shouldUsePkce(linkedin, "")).toBe(true);
234+
expect(
235+
shouldUsePkce("https://www.linkedin.com/oauth/native-pkce/authorization", "secret"),
236+
).toBe(true);
237+
expect(shouldUsePkce("http://www.linkedin.com/oauth/v2/authorization", "secret")).toBe(true);
238+
expect(shouldUsePkce("https://www.linkedin.com:8443/oauth/v2/authorization", "secret")).toBe(
239+
true,
240+
);
241+
expect(shouldUsePkce("https://accounts.google.com/o/oauth2/v2/auth", "secret")).toBe(true);
242+
});
243+
});
244+
228245
describe("buildAuthorizationUrl", () => {
229246
const baseInput = {
230247
authorizationUrl: "https://example.com/authorize",
@@ -249,6 +266,19 @@ describe("buildAuthorizationUrl", () => {
249266
);
250267
});
251268

269+
it("omits PKCE params when no challenge is supplied", () => {
270+
const { codeChallenge: _, ...withoutPkce } = baseInput;
271+
const url = new URL(
272+
buildAuthorizationUrl({
273+
...withoutPkce,
274+
authorizationUrl:
275+
"https://example.com/authorize?code_challenge=stale&code_challenge_method=S256",
276+
}),
277+
);
278+
expect(url.searchParams.has("code_challenge_method")).toBe(false);
279+
expect(url.searchParams.has("code_challenge")).toBe(false);
280+
});
281+
252282
it("supports a custom scope separator (e.g. comma for legacy providers)", () => {
253283
const url = new URL(buildAuthorizationUrl({ ...baseInput, scopeSeparator: "," }));
254284
expect(url.searchParams.get("scope")).toBe("read,write");
@@ -329,6 +359,23 @@ describe("buildAuthorizationUrl", () => {
329359
});
330360

331361
describe("exchangeAuthorizationCode", () => {
362+
it.effect("omits the PKCE verifier for a confidential flow that does not use PKCE", () =>
363+
withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
364+
Effect.gen(function* () {
365+
yield* exchangeAuthorizationCode({
366+
tokenUrl,
367+
clientId: "cid",
368+
clientSecret: "csecret",
369+
redirectUrl: "https://app.example.com/cb",
370+
code: "abc",
371+
});
372+
const call = (yield* calls)[0]!;
373+
expect(call.body.get("client_secret")).toBe("csecret");
374+
expect(call.body.has("code_verifier")).toBe(false);
375+
}),
376+
),
377+
);
378+
332379
it.effect("supports JSON token exchange with HTTP Basic client authentication", () =>
333380
withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
334381
Effect.gen(function* () {

‎packages/core/sdk/src/oauth-helpers.ts‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,20 @@ export const createPkceCodeChallenge = (verifier: string): Promise<string> =>
191191
* and redeemed by `oauth.complete`. */
192192
export const createOAuthState = (): string => oauth.generateRandomState();
193193

194+
/** LinkedIn's standard confidential web flow rejects token requests that
195+
* include PKCE material. Its separate native endpoint supports PKCE, so keep
196+
* the exception tied to the documented web authorization endpoint and only
197+
* apply it when the client has a secret. */
198+
export const shouldUsePkce = (authorizationUrl: string, clientSecret?: string | null): boolean => {
199+
if (!clientSecret) return true;
200+
if (!URL.canParse(authorizationUrl)) return true;
201+
const url = new URL(authorizationUrl);
202+
return !(
203+
url.origin.toLowerCase() === "https://www.linkedin.com" &&
204+
url.pathname === "/oauth/v2/authorization"
205+
);
206+
};
207+
194208
// ---------------------------------------------------------------------------
195209
// Authorization URL builder
196210
// ---------------------------------------------------------------------------
@@ -202,7 +216,7 @@ export type BuildAuthorizationUrlInput = {
202216
readonly scopes: readonly string[];
203217
readonly state: string;
204218
/** Pre-computed base64url S256 challenge (from `createPkceCodeChallenge`). */
205-
readonly codeChallenge: string;
219+
readonly codeChallenge?: string;
206220
/** Separator between scopes. RFC 6749 says space; some providers use comma. */
207221
readonly scopeSeparator?: string;
208222
/** RFC 8707 Resource Indicator. MCP Authorization 2025-06-18 §"Resource
@@ -235,8 +249,13 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string
235249
url.searchParams.set("scope", input.scopes.join(separator));
236250
}
237251
url.searchParams.set("state", input.state);
238-
url.searchParams.set("code_challenge_method", "S256");
239-
url.searchParams.set("code_challenge", input.codeChallenge);
252+
if (input.codeChallenge) {
253+
url.searchParams.set("code_challenge_method", "S256");
254+
url.searchParams.set("code_challenge", input.codeChallenge);
255+
} else {
256+
url.searchParams.delete("code_challenge_method");
257+
url.searchParams.delete("code_challenge");
258+
}
240259
if (input.resource) {
241260
url.searchParams.set("resource", input.resource);
242261
}
@@ -1213,7 +1232,7 @@ export type ExchangeAuthorizationCodeInput = {
12131232
readonly clientId: string;
12141233
readonly clientSecret?: string | null;
12151234
readonly redirectUrl: string;
1216-
readonly codeVerifier: string;
1235+
readonly codeVerifier?: string;
12171236
readonly code: string;
12181237
readonly clientAuth?: ClientAuthMethod;
12191238
/** Encoding required by the provider's token endpoint. OAuth defaults to
@@ -1300,8 +1319,10 @@ export const exchangeAuthorizationCode = (
13001319
const params = new URLSearchParams({
13011320
code: input.code,
13021321
redirect_uri: input.redirectUrl,
1303-
code_verifier: input.codeVerifier,
13041322
});
1323+
if (input.codeVerifier) {
1324+
params.set("code_verifier", input.codeVerifier);
1325+
}
13051326
if (input.resource) {
13061327
params.set("resource", input.resource);
13071328
}

‎packages/core/sdk/src/oauth-service.ts‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ import {
106106
exchangeClientCredentials,
107107
isLoopbackHttpUrl,
108108
rebindTokenEndpointHostToCallbackDomain,
109+
shouldUsePkce,
109110
type OAuth2TokenResponse,
110111
type OAuthEndpointUrlPolicy,
111112
} from "./oauth-helpers";
@@ -2048,9 +2049,14 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
20482049
...workspaceOptionalScopes,
20492050
]);
20502051

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

2233-
// The PKCE verifier is minted by `start` for every authorization_code
2234-
// session. A null/missing one means a corrupt session row — exchanging
2235-
// with an empty verifier would violate RFC 7636 and the AS would reject
2236-
// it with an opaque error. Fail loudly + require a restart instead.
2237-
if (session.pkceVerifier == null) {
2239+
const usePkce = shouldUsePkce(client.authorizationUrl, client.clientSecret);
2240+
// Every authorization-code flow except LinkedIn's confidential web flow
2241+
// requires the verifier minted by `start`. Missing one is a corrupt row.
2242+
if (usePkce && session.pkceVerifier == null) {
22382243
return yield* new OAuthCompleteError({
22392244
message: `OAuth session ${input.state} is missing its PKCE code verifier; restart the flow.`,
22402245
restartRequired: true,
@@ -2256,7 +2261,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
22562261
clientId: client.clientId,
22572262
clientSecret: client.clientSecret,
22582263
redirectUrl: session.redirectUrl,
2259-
codeVerifier: session.pkceVerifier,
2264+
codeVerifier: usePkce ? (session.pkceVerifier ?? undefined) : undefined,
22602265
code: input.code,
22612266
clientAuth: client.tokenEndpointAuthMethod,
22622267
requestFormat: client.tokenRequestFormat,

0 commit comments

Comments
 (0)