From 133348125843281ab2ba52f3a567c6486f2048bd Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:08:50 -0700 Subject: [PATCH 1/3] Support raw OAuth Basic credentials --- .changeset/raw-oauth-basic.md | 5 ++ packages/core/sdk/src/executor.ts | 3 +- packages/core/sdk/src/oauth-client.ts | 14 +++-- packages/core/sdk/src/oauth-flow.test.ts | 62 +++++++++++++++++++ packages/core/sdk/src/oauth-helpers.test.ts | 31 ++++++++-- packages/core/sdk/src/oauth-helpers.ts | 56 ++++++++++------- packages/core/sdk/src/oauth-service.ts | 11 +++- packages/react/src/api/atoms.tsx | 3 +- .../src/components/oauth-client-form.test.ts | 11 ++++ .../src/components/oauth-client-form.tsx | 13 +++- 10 files changed, 171 insertions(+), 38 deletions(-) create mode 100644 .changeset/raw-oauth-basic.md diff --git a/.changeset/raw-oauth-basic.md b/.changeset/raw-oauth-basic.md new file mode 100644 index 000000000..aacb49575 --- /dev/null +++ b/.changeset/raw-oauth-basic.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Add a raw HTTP Basic compatibility mode for OAuth providers that reject form-encoded client credentials. diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index ab91ac0ec..b31e60149 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -135,6 +135,7 @@ import { isFirstPartyOAuthClientSlug, parseStoredTokenEndpointAuthMethod, type OAuthService, + type TokenEndpointAuthMethod, } from "./oauth-client"; import type { FirstPartyOAuthClientConfig } from "./oauth-client"; import { @@ -2146,7 +2147,7 @@ export const createExecutor = - value === "body" || value === "basic"; + value === "body" || value === "basic" || value === "basic_raw"; /** Decode a nullable stored value. `undefined` is the legacy/default body * method; `null` means the row contains an invalid non-null value. */ @@ -221,7 +225,9 @@ export interface FirstPartyOAuthClientConfig { * them. */ readonly authorizationExtraParams?: Readonly>; /** Token endpoint client-auth transport. Omitted means - * `client_secret_post`; `basic` sends the secret only in HTTP Basic auth. */ + * `client_secret_post`; `basic` uses the RFC form-encoded HTTP Basic form; + * `basic_raw` is an explicit compatibility mode for providers that require + * the literal client id and secret before Base64 encoding. */ readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; /** Token endpoint request encoding. OAuth defaults to URL-encoded form; * providers such as Atlassian, ClickUp, and Notion require JSON. */ diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts index 8d981f0fc..b9860574b 100644 --- a/packages/core/sdk/src/oauth-flow.test.ts +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -327,6 +327,68 @@ describe("oauth.start / oauth.complete", () => { ), ); + it.effect("persists raw HTTP Basic credentials for code exchange and refresh", () => + Effect.scoped( + Effect.gen(function* () { + const clientId = "test-client"; + const clientSecret = "test-secret"; + const server = yield* serveOAuthTestServer({ + scopes: ["read"], + defaultTokenEndpointAuthMethod: "client_secret_basic", + }); + const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId, + clientSecret, + tokenEndpointAuthMethod: "basic_raw", + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("raw-basic-client"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ state: started.state, code: callback.code }); + + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "rawBasicClient"), + set: { expires_at: Date.now() - 60_000 }, + }), + ); + yield* executor.execute(ToolAddress.make("tools.acme.org.rawBasicClient.whoami"), {}); + + const tokenRequests = (yield* server.requests).filter( + (request) => request.path === "/token" && request.method === "POST", + ); + expect(tokenRequests).toHaveLength(2); + const expectedAuthorization = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`; + for (const request of tokenRequests) { + expect(request.headers.authorization).toBe(expectedAuthorization); + expect(request.body).not.toContain("client_secret="); + } + expect(tokenRequests[0]?.body).toContain("grant_type=authorization_code"); + expect(tokenRequests[1]?.body).toContain("grant_type=refresh_token"); + }), + ), + ); + it.effect("carries the URL org selector in provider state without changing redirect_uri", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index 99e7090ab..3b832354c 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -312,7 +312,7 @@ describe("exchangeAuthorizationCode", () => { yield* exchangeAuthorizationCode({ tokenUrl, clientId: "cid", - clientSecret: "csecret", + clientSecret: "c-secret", redirectUrl: "https://app.example.com/cb", codeVerifier: "verifier", code: "abc", @@ -321,7 +321,7 @@ describe("exchangeAuthorizationCode", () => { }); const call = (yield* calls)[0]!; expect(call.headers["content-type"]).toBe("application/json"); - expect(call.headers["authorization"]).toBe("Basic Y2lkOmNzZWNyZXQ="); + expect(call.headers["authorization"]).toBe("Basic Y2lkOmMlMkRzZWNyZXQ="); expect(call.jsonBody).toEqual({ grant_type: "authorization_code", code: "abc", @@ -811,14 +811,37 @@ describe("exchangeAuthorizationCode", () => { yield* exchangeAuthorizationCode({ tokenUrl, clientId: "cid", - clientSecret: "csecret", + clientSecret: "c-secret", redirectUrl: "https://app.example.com/cb", codeVerifier: "verifier", code: "abc", clientAuth: "basic", }); const call = (yield* calls)[0]!; - const expected = `Basic ${Buffer.from("cid:csecret").toString("base64")}`; + const expected = `Basic ${Buffer.from("cid:c%2Dsecret").toString("base64")}`; + expect(call.headers["authorization"]).toBe(expected); + expect(call.body.has("client_id")).toBe(false); + expect(call.body.has("client_secret")).toBe(false); + }), + ), + ); + + it.effect("uses literal Basic credentials when clientAuth=basic_raw", () => + withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => + Effect.gen(function* () { + const clientId = "client-id"; + const clientSecret = "secret-_~.!*'()"; + yield* exchangeAuthorizationCode({ + tokenUrl, + clientId, + clientSecret, + redirectUrl: "https://app.example.com/cb", + codeVerifier: "verifier", + code: "abc", + clientAuth: "basic_raw", + }); + const call = (yield* calls)[0]!; + const expected = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`; expect(call.headers["authorization"]).toBe(expected); expect(call.body.has("client_id")).toBe(false); expect(call.body.has("client_secret")).toBe(false); diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index f3cb2baa9..2f7c39fff 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -19,7 +19,7 @@ import { Data, Effect, Option, Predicate, Schema } from "effect"; import * as oauth from "oauth4webapi"; -import type { SubjectTokenType } from "./oauth-client"; +import type { SubjectTokenType, TokenEndpointAuthMethod } from "./oauth-client"; // --------------------------------------------------------------------------- // Errors @@ -842,7 +842,7 @@ const hostnameForTelemetry = (url: string): string => URL.parse(url)?.hostname ? // oauth4webapi adapter helpers // --------------------------------------------------------------------------- -export type ClientAuthMethod = "body" | "basic"; +export type ClientAuthMethod = TokenEndpointAuthMethod; /** * The token-endpoint client-auth transport used when a caller doesn't specify @@ -850,8 +850,10 @@ export type ClientAuthMethod = "body" | "basic"; * method our DCR registers (`token_endpoint_auth_method: client_secret_post`) * and the one every confidential client in the v2 model uses. EXPLICIT and * documented rather than a hidden inline `?? "body"`: callers that need - * `client_secret_basic` pass `clientAuth: "basic"`. For PUBLIC clients (no - * secret) the method is irrelevant — `pickClientAuth` returns `None()`. + * `client_secret_basic` pass `clientAuth: "basic"`. Providers that reject the + * RFC form encoding can explicitly pass `clientAuth: "basic_raw"`. For PUBLIC + * clients (no secret) the method is irrelevant — `pickClientAuth` returns + * `None()`. */ export const DEFAULT_CLIENT_AUTH_METHOD: ClientAuthMethod = "body"; @@ -914,15 +916,29 @@ const oauth4webapiRequestOptions = ( // (public PKCE — `None()`, RFC 7636). This is not a silent guess: `loadClient` // persists a non-empty secret for confidential clients and null/"" for public // ones, so an absent secret here unambiguously means "public client". The -// `method` only chooses HOW a present secret is sent (post vs basic). +// `method` only chooses HOW a present secret is sent (post vs either Basic +// credential encoding). +const base64BasicCredentials = (clientId: string, clientSecret: string): string => { + const bytes = new TextEncoder().encode(`${clientId}:${clientSecret}`); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return globalThis.btoa(binary); +}; + +const rawClientSecretBasic = + (clientSecret: string): oauth.ClientAuth => + (_authorizationServer, client, _body, headers) => { + headers.set("authorization", `Basic ${base64BasicCredentials(client.client_id, clientSecret)}`); + }; + const pickClientAuth = ( clientSecret: string | null | undefined, method: ClientAuthMethod, ): oauth.ClientAuth => { if (!clientSecret) return oauth.None(); - return method === "basic" - ? oauth.ClientSecretBasic(clientSecret) - : oauth.ClientSecretPost(clientSecret); + if (method === "basic") return oauth.ClientSecretBasic(clientSecret); + if (method === "basic_raw") return rawClientSecretBasic(clientSecret); + return oauth.ClientSecretPost(clientSecret); }; const normalizedTokenScope = ( @@ -1122,13 +1138,6 @@ export type ExchangeAuthorizationCodeInput = { readonly fetch?: typeof globalThis.fetch; }; -const base64BasicCredentials = (clientId: string, clientSecret: string): string => { - const bytes = new TextEncoder().encode(`${clientId}:${clientSecret}`); - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return globalThis.btoa(binary); -}; - const jsonTokenEndpointRequest = async (input: { readonly tokenUrl: string; readonly clientId: string; @@ -1149,21 +1158,24 @@ const jsonTokenEndpointRequest = async (input: { accept: "application/json", "content-type": "application/json", }); - const confidential = Boolean(input.clientSecret); - if (confidential && input.clientAuth === "basic") { - headers.set( - "authorization", - `Basic ${base64BasicCredentials(input.clientId, input.clientSecret ?? "")}`, + const clientSecret = input.clientSecret ?? ""; + const confidential = clientSecret.length > 0; + if (confidential && input.clientAuth !== "body") { + await pickClientAuth(clientSecret, input.clientAuth)( + asFromTokenUrl(tokenUrl, input.endpointUrlPolicy), + { client_id: input.clientId }, + new URLSearchParams(), + headers, ); } const body = { grant_type: input.grantType, ...input.parameters, - ...(confidential && input.clientAuth === "basic" + ...(confidential && input.clientAuth !== "body" ? {} : { client_id: input.clientId, - ...(confidential ? { client_secret: input.clientSecret ?? "" } : {}), + ...(confidential ? { client_secret: clientSecret } : {}), }), }; // oxlint-disable-next-line executor/no-raw-fetch -- boundary: provider token exchange is the SDK's HTTP boundary and preserves its injected fetch seam diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 2e6f05d5b..484d5cdad 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -55,6 +55,7 @@ import { type OAuthStartInput, type RegisterDynamicClientInput, type SubjectTokenType, + type TokenEndpointAuthMethod, } from "./oauth-client"; import type { OwnerBinding } from "./plugin"; import type { CredentialProvider } from "./provider"; @@ -517,7 +518,7 @@ interface LoadedOAuthClient { /** Resolved literal secret (read from the provider via the stored item id). */ readonly clientSecret: string; readonly resource: string | null; - readonly tokenEndpointAuthMethod?: "body" | "basic"; + readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; readonly tokenRequestFormat?: "form" | "json"; } @@ -621,7 +622,7 @@ export const loadedFirstPartyClient = ( readonly clientId: string; readonly clientSecret: string; readonly resource: string | null; - readonly tokenEndpointAuthMethod?: "body" | "basic"; + readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; readonly tokenRequestFormat?: "form" | "json"; } => ({ slug: String(firstPartyOAuthClientSlug(config.name)), @@ -858,7 +859,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }); } yield* validateClientEndpoints(input, deps.endpointUrlPolicy); - if (input.tokenEndpointAuthMethod === "basic" && input.clientSecret.length === 0) { + if ( + input.tokenEndpointAuthMethod !== undefined && + input.tokenEndpointAuthMethod !== "body" && + input.clientSecret.length === 0 + ) { return yield* new StorageError({ message: "HTTP Basic token endpoint authentication requires a client secret.", cause: undefined, diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 7dbeab7b2..0d6fb9af7 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -12,6 +12,7 @@ import { type OAuthGrant, type Owner, type ProviderItemId, + type TokenEndpointAuthMethod, type ToolAddress, } from "@executor-js/sdk/shared"; import * as Atom from "effect/unstable/reactivity/Atom"; @@ -571,7 +572,7 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( readonly tokenUrl: string; readonly grant: OAuthGrant; readonly clientId: string; - readonly tokenEndpointAuthMethod?: "body" | "basic"; + readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; readonly resource?: string | null; readonly originIntegration?: IntegrationSlug | null; }; diff --git a/packages/react/src/components/oauth-client-form.test.ts b/packages/react/src/components/oauth-client-form.test.ts index a06bdeea5..ddd3dd537 100644 --- a/packages/react/src/components/oauth-client-form.test.ts +++ b/packages/react/src/components/oauth-client-form.test.ts @@ -124,6 +124,17 @@ describe("canSubmitOAuthClientForm", () => { }), ).toBe(false); }); + + it("requires a secret when raw HTTP Basic is selected", () => { + expect( + canSubmitOAuthClientForm({ + ...validBase, + grant: "authorization_code", + clientSecret: "", + tokenEndpointAuthMethod: "basic_raw", + }), + ).toBe(false); + }); }); describe("preferredManualTokenEndpointAuthMethod", () => { diff --git a/packages/react/src/components/oauth-client-form.tsx b/packages/react/src/components/oauth-client-form.tsx index 6bb591ca0..74ee1f654 100644 --- a/packages/react/src/components/oauth-client-form.tsx +++ b/packages/react/src/components/oauth-client-form.tsx @@ -116,7 +116,9 @@ export const canSubmitOAuthClientForm = (input: { input.name.trim().length > 0 && input.clientId.trim().length > 0 && (input.grant === "authorization_code" || input.clientSecret.trim().length > 0) && - (input.tokenEndpointAuthMethod !== "basic" || input.clientSecret.trim().length > 0) && + (input.tokenEndpointAuthMethod === undefined || + input.tokenEndpointAuthMethod === "body" || + input.clientSecret.trim().length > 0) && input.tokenUrl.trim().length > 0 && (input.grant === "client_credentials" || input.authorizationUrl.trim().length > 0); @@ -578,7 +580,7 @@ export function OAuthClientForm(props: { onValueChange={(next: string) => { if (isTokenEndpointAuthMethod(next)) setTokenEndpointAuthMethod(next); }} - className="grid gap-2 sm:grid-cols-2" + className="grid gap-2 sm:grid-cols-3" > {( [ @@ -592,6 +594,11 @@ export function OAuthClientForm(props: { label: "HTTP Basic", hint: "client_secret_basic", }, + { + value: "basic_raw", + label: "HTTP Basic (raw)", + hint: "provider compatibility", + }, ] as const ).map((option) => (