Skip to content

Commit b5271a6

Browse files
authored
Support raw OAuth Basic credentials (#1907)
* Support raw OAuth Basic credentials * Fix OAuth app E2E selector * Stabilize cloud session cap E2E
1 parent e8590a0 commit b5271a6

12 files changed

Lines changed: 182 additions & 45 deletions

.changeset/raw-oauth-basic.md

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+
Add a raw HTTP Basic compatibility mode for OAuth providers that reject form-encoded client credentials.

e2e/cloud/mcp-session-cap-eviction.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,12 @@ scenario(
147147
const openedSessionIds: string[] = [];
148148

149149
const scenarioBody = Effect.gen(function* () {
150-
// Open more sessions than the cap allows, at limited concurrency. None
151-
// of them run any work, so every one is immediately eviction-eligible —
152-
// crossing the cap must pick at least one and tear it down through its
153-
// own stub.
150+
// Open more sessions than the cap allows. Keep admission sequential:
151+
// the cloud e2e database is one serialized PGlite instance, and this
152+
// scenario exercises resident eviction rather than concurrent cold
153+
// builds. None of the sessions run any work, so every one is immediately
154+
// eviction-eligible — crossing the cap must pick at least one and tear it
155+
// down through its own stub.
154156
const sessionIds = yield* Effect.forEach(
155157
Array.from({ length: SESSIONS_TO_OPEN }, (_, index) => index),
156158
(index) =>
@@ -159,7 +161,7 @@ scenario(
159161
openedSessionIds.push(sessionId);
160162
}),
161163
),
162-
{ concurrency: 8 },
164+
{ concurrency: 1 },
163165
);
164166

165167
expect(sessionIds.length, "every session opened").toBe(SESSIONS_TO_OPEN);

e2e/selfhost/oauth-app-modal.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ scenario(
9696
await page.locator("#oauth-app-name").fill(appName);
9797
await page.locator("#oauth-client-id").fill("client-one");
9898
await page.locator("#oauth-client-secret").fill("secret-one");
99-
await page.getByRole("radio", { name: /HTTP Basic/ }).check();
99+
await page.getByRole("radio", { name: /^HTTP Basic client_secret_basic$/ }).check();
100100
await page.getByRole("button", { name: "Register app", exact: true }).click();
101101
// Back on the picker, the new app is selectable AND manageable —
102102
// the per-app actions menu is what replaced the old apps page.
@@ -112,7 +112,9 @@ scenario(
112112
"the edit form prefills the stored client id",
113113
).toBe("client-one");
114114
await expect
115-
.poll(() => page.getByRole("radio", { name: /HTTP Basic/ }).isChecked())
115+
.poll(() =>
116+
page.getByRole("radio", { name: /^HTTP Basic client_secret_basic$/ }).isChecked(),
117+
)
116118
.toBe(true);
117119
});
118120

packages/core/sdk/src/executor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ import {
135135
isFirstPartyOAuthClientSlug,
136136
parseStoredTokenEndpointAuthMethod,
137137
type OAuthService,
138+
type TokenEndpointAuthMethod,
138139
} from "./oauth-client";
139140
import type { FirstPartyOAuthClientConfig } from "./oauth-client";
140141
import {
@@ -2146,7 +2147,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
21462147
readonly tokenUrl: string;
21472148
readonly grant: string;
21482149
readonly resource: string | null;
2149-
readonly tokenEndpointAuthMethod?: "body" | "basic";
2150+
readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
21502151
readonly tokenRequestFormat?: "form" | "json";
21512152
}
21522153

packages/core/sdk/src/oauth-client.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,19 @@ export const DEFAULT_SUBJECT_TOKEN_TYPE: SubjectTokenType =
3939
"urn:ietf:params:oauth:token-type:id_token";
4040

4141
/** How a confidential OAuth client authenticates to its token endpoint. */
42-
export const TokenEndpointAuthMethodSchema = Schema.Literals(["body", "basic"]).annotate({
42+
export const TokenEndpointAuthMethodSchema = Schema.Literals([
43+
"body",
44+
"basic",
45+
"basic_raw",
46+
]).annotate({
4347
identifier: "TokenEndpointAuthMethod",
4448
description:
45-
"Transport for a confidential OAuth client secret: request body (client_secret_post) or HTTP Basic (client_secret_basic).",
49+
"Transport for a confidential OAuth client secret: request body (client_secret_post), standards-based HTTP Basic (client_secret_basic), or raw HTTP Basic for providers that reject form-encoded credentials.",
4650
});
4751
export type TokenEndpointAuthMethod = typeof TokenEndpointAuthMethodSchema.Type;
4852

4953
export const isTokenEndpointAuthMethod = (value: unknown): value is TokenEndpointAuthMethod =>
50-
value === "body" || value === "basic";
54+
value === "body" || value === "basic" || value === "basic_raw";
5155

5256
/** Decode a nullable stored value. `undefined` is the legacy/default body
5357
* method; `null` means the row contains an invalid non-null value. */
@@ -221,7 +225,9 @@ export interface FirstPartyOAuthClientConfig {
221225
* them. */
222226
readonly authorizationExtraParams?: Readonly<Record<string, string>>;
223227
/** Token endpoint client-auth transport. Omitted means
224-
* `client_secret_post`; `basic` sends the secret only in HTTP Basic auth. */
228+
* `client_secret_post`; `basic` uses the RFC form-encoded HTTP Basic form;
229+
* `basic_raw` is an explicit compatibility mode for providers that require
230+
* the literal client id and secret before Base64 encoding. */
225231
readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
226232
/** Token endpoint request encoding. OAuth defaults to URL-encoded form;
227233
* providers such as Atlassian, ClickUp, and Notion require JSON. */

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,68 @@ describe("oauth.start / oauth.complete", () => {
327327
),
328328
);
329329

330+
it.effect("persists raw HTTP Basic credentials for code exchange and refresh", () =>
331+
Effect.scoped(
332+
Effect.gen(function* () {
333+
const clientId = "test-client";
334+
const clientSecret = "test-secret";
335+
const server = yield* serveOAuthTestServer({
336+
scopes: ["read"],
337+
defaultTokenEndpointAuthMethod: "client_secret_basic",
338+
});
339+
const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
340+
yield* executor.acme.seed();
341+
342+
yield* executor.oauth.createClient({
343+
owner: "org",
344+
slug: CLIENT,
345+
authorizationUrl: server.authorizationEndpoint,
346+
tokenUrl: server.tokenEndpoint,
347+
grant: "authorization_code",
348+
clientId,
349+
clientSecret,
350+
tokenEndpointAuthMethod: "basic_raw",
351+
});
352+
353+
const started = yield* executor.oauth.start({
354+
owner: "org",
355+
client: CLIENT,
356+
clientOwner: "org",
357+
name: ConnectionName.make("raw-basic-client"),
358+
integration: INTEG,
359+
template: TEMPLATE,
360+
});
361+
expect(started.status).toBe("redirect");
362+
if (started.status !== "redirect") return;
363+
364+
const callback = yield* server.completeAuthorizationCodeFlow({
365+
authorizationUrl: started.authorizationUrl,
366+
});
367+
yield* executor.oauth.complete({ state: started.state, code: callback.code });
368+
369+
yield* Effect.promise(() =>
370+
config.db.updateMany("connection", {
371+
where: (b) => b("name", "=", "rawBasicClient"),
372+
set: { expires_at: Date.now() - 60_000 },
373+
}),
374+
);
375+
yield* executor.execute(ToolAddress.make("tools.acme.org.rawBasicClient.whoami"), {});
376+
377+
const tokenRequests = (yield* server.requests).filter(
378+
(request) => request.path === "/token" && request.method === "POST",
379+
);
380+
expect(tokenRequests).toHaveLength(2);
381+
const expectedAuthorization = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`;
382+
for (const request of tokenRequests) {
383+
expect(request.headers.authorization).toBe(expectedAuthorization);
384+
expect(request.body).not.toContain("client_secret=");
385+
}
386+
expect(tokenRequests[0]?.body).toContain("grant_type=authorization_code");
387+
expect(tokenRequests[1]?.body).toContain("grant_type=refresh_token");
388+
}),
389+
),
390+
);
391+
330392
it.effect("carries the URL org selector in provider state without changing redirect_uri", () =>
331393
Effect.scoped(
332394
Effect.gen(function* () {

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

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ describe("exchangeAuthorizationCode", () => {
312312
yield* exchangeAuthorizationCode({
313313
tokenUrl,
314314
clientId: "cid",
315-
clientSecret: "csecret",
315+
clientSecret: "c-secret",
316316
redirectUrl: "https://app.example.com/cb",
317317
codeVerifier: "verifier",
318318
code: "abc",
@@ -321,7 +321,7 @@ describe("exchangeAuthorizationCode", () => {
321321
});
322322
const call = (yield* calls)[0]!;
323323
expect(call.headers["content-type"]).toBe("application/json");
324-
expect(call.headers["authorization"]).toBe("Basic Y2lkOmNzZWNyZXQ=");
324+
expect(call.headers["authorization"]).toBe("Basic Y2lkOmMlMkRzZWNyZXQ=");
325325
expect(call.jsonBody).toEqual({
326326
grant_type: "authorization_code",
327327
code: "abc",
@@ -811,14 +811,37 @@ describe("exchangeAuthorizationCode", () => {
811811
yield* exchangeAuthorizationCode({
812812
tokenUrl,
813813
clientId: "cid",
814-
clientSecret: "csecret",
814+
clientSecret: "c-secret",
815815
redirectUrl: "https://app.example.com/cb",
816816
codeVerifier: "verifier",
817817
code: "abc",
818818
clientAuth: "basic",
819819
});
820820
const call = (yield* calls)[0]!;
821-
const expected = `Basic ${Buffer.from("cid:csecret").toString("base64")}`;
821+
const expected = `Basic ${Buffer.from("cid:c%2Dsecret").toString("base64")}`;
822+
expect(call.headers["authorization"]).toBe(expected);
823+
expect(call.body.has("client_id")).toBe(false);
824+
expect(call.body.has("client_secret")).toBe(false);
825+
}),
826+
),
827+
);
828+
829+
it.effect("uses literal Basic credentials when clientAuth=basic_raw", () =>
830+
withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
831+
Effect.gen(function* () {
832+
const clientId = "client-id";
833+
const clientSecret = "secret-_~.!*'()";
834+
yield* exchangeAuthorizationCode({
835+
tokenUrl,
836+
clientId,
837+
clientSecret,
838+
redirectUrl: "https://app.example.com/cb",
839+
codeVerifier: "verifier",
840+
code: "abc",
841+
clientAuth: "basic_raw",
842+
});
843+
const call = (yield* calls)[0]!;
844+
const expected = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`;
822845
expect(call.headers["authorization"]).toBe(expected);
823846
expect(call.body.has("client_id")).toBe(false);
824847
expect(call.body.has("client_secret")).toBe(false);

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

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import { Data, Effect, Option, Predicate, Schema } from "effect";
2020
import * as oauth from "oauth4webapi";
2121

22-
import type { SubjectTokenType } from "./oauth-client";
22+
import type { SubjectTokenType, TokenEndpointAuthMethod } from "./oauth-client";
2323

2424
// ---------------------------------------------------------------------------
2525
// Errors
@@ -842,16 +842,18 @@ const hostnameForTelemetry = (url: string): string => URL.parse(url)?.hostname ?
842842
// oauth4webapi adapter helpers
843843
// ---------------------------------------------------------------------------
844844

845-
export type ClientAuthMethod = "body" | "basic";
845+
export type ClientAuthMethod = TokenEndpointAuthMethod;
846846

847847
/**
848848
* The token-endpoint client-auth transport used when a caller doesn't specify
849849
* one. `"body"` is `client_secret_post` (the secret in the form body) — the
850850
* method our DCR registers (`token_endpoint_auth_method: client_secret_post`)
851851
* and the one every confidential client in the v2 model uses. EXPLICIT and
852852
* documented rather than a hidden inline `?? "body"`: callers that need
853-
* `client_secret_basic` pass `clientAuth: "basic"`. For PUBLIC clients (no
854-
* secret) the method is irrelevant — `pickClientAuth` returns `None()`.
853+
* `client_secret_basic` pass `clientAuth: "basic"`. Providers that reject the
854+
* RFC form encoding can explicitly pass `clientAuth: "basic_raw"`. For PUBLIC
855+
* clients (no secret) the method is irrelevant — `pickClientAuth` returns
856+
* `None()`.
855857
*/
856858
export const DEFAULT_CLIENT_AUTH_METHOD: ClientAuthMethod = "body";
857859

@@ -914,15 +916,29 @@ const oauth4webapiRequestOptions = (
914916
// (public PKCE — `None()`, RFC 7636). This is not a silent guess: `loadClient`
915917
// persists a non-empty secret for confidential clients and null/"" for public
916918
// ones, so an absent secret here unambiguously means "public client". The
917-
// `method` only chooses HOW a present secret is sent (post vs basic).
919+
// `method` only chooses HOW a present secret is sent (post vs either Basic
920+
// credential encoding).
921+
const base64BasicCredentials = (clientId: string, clientSecret: string): string => {
922+
const bytes = new TextEncoder().encode(`${clientId}:${clientSecret}`);
923+
let binary = "";
924+
for (const byte of bytes) binary += String.fromCharCode(byte);
925+
return globalThis.btoa(binary);
926+
};
927+
928+
const rawClientSecretBasic =
929+
(clientSecret: string): oauth.ClientAuth =>
930+
(_authorizationServer, client, _body, headers) => {
931+
headers.set("authorization", `Basic ${base64BasicCredentials(client.client_id, clientSecret)}`);
932+
};
933+
918934
const pickClientAuth = (
919935
clientSecret: string | null | undefined,
920936
method: ClientAuthMethod,
921937
): oauth.ClientAuth => {
922938
if (!clientSecret) return oauth.None();
923-
return method === "basic"
924-
? oauth.ClientSecretBasic(clientSecret)
925-
: oauth.ClientSecretPost(clientSecret);
939+
if (method === "basic") return oauth.ClientSecretBasic(clientSecret);
940+
if (method === "basic_raw") return rawClientSecretBasic(clientSecret);
941+
return oauth.ClientSecretPost(clientSecret);
926942
};
927943

928944
const normalizedTokenScope = (
@@ -1122,13 +1138,6 @@ export type ExchangeAuthorizationCodeInput = {
11221138
readonly fetch?: typeof globalThis.fetch;
11231139
};
11241140

1125-
const base64BasicCredentials = (clientId: string, clientSecret: string): string => {
1126-
const bytes = new TextEncoder().encode(`${clientId}:${clientSecret}`);
1127-
let binary = "";
1128-
for (const byte of bytes) binary += String.fromCharCode(byte);
1129-
return globalThis.btoa(binary);
1130-
};
1131-
11321141
const jsonTokenEndpointRequest = async (input: {
11331142
readonly tokenUrl: string;
11341143
readonly clientId: string;
@@ -1149,21 +1158,24 @@ const jsonTokenEndpointRequest = async (input: {
11491158
accept: "application/json",
11501159
"content-type": "application/json",
11511160
});
1152-
const confidential = Boolean(input.clientSecret);
1153-
if (confidential && input.clientAuth === "basic") {
1154-
headers.set(
1155-
"authorization",
1156-
`Basic ${base64BasicCredentials(input.clientId, input.clientSecret ?? "")}`,
1161+
const clientSecret = input.clientSecret ?? "";
1162+
const confidential = clientSecret.length > 0;
1163+
if (confidential && input.clientAuth !== "body") {
1164+
await pickClientAuth(clientSecret, input.clientAuth)(
1165+
asFromTokenUrl(tokenUrl, input.endpointUrlPolicy),
1166+
{ client_id: input.clientId },
1167+
new URLSearchParams(),
1168+
headers,
11571169
);
11581170
}
11591171
const body = {
11601172
grant_type: input.grantType,
11611173
...input.parameters,
1162-
...(confidential && input.clientAuth === "basic"
1174+
...(confidential && input.clientAuth !== "body"
11631175
? {}
11641176
: {
11651177
client_id: input.clientId,
1166-
...(confidential ? { client_secret: input.clientSecret ?? "" } : {}),
1178+
...(confidential ? { client_secret: clientSecret } : {}),
11671179
}),
11681180
};
11691181
// oxlint-disable-next-line executor/no-raw-fetch -- boundary: provider token exchange is the SDK's HTTP boundary and preserves its injected fetch seam

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
type OAuthStartInput,
5656
type RegisterDynamicClientInput,
5757
type SubjectTokenType,
58+
type TokenEndpointAuthMethod,
5859
} from "./oauth-client";
5960
import type { OwnerBinding } from "./plugin";
6061
import type { CredentialProvider } from "./provider";
@@ -517,7 +518,7 @@ interface LoadedOAuthClient {
517518
/** Resolved literal secret (read from the provider via the stored item id). */
518519
readonly clientSecret: string;
519520
readonly resource: string | null;
520-
readonly tokenEndpointAuthMethod?: "body" | "basic";
521+
readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
521522
readonly tokenRequestFormat?: "form" | "json";
522523
}
523524

@@ -621,7 +622,7 @@ export const loadedFirstPartyClient = (
621622
readonly clientId: string;
622623
readonly clientSecret: string;
623624
readonly resource: string | null;
624-
readonly tokenEndpointAuthMethod?: "body" | "basic";
625+
readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
625626
readonly tokenRequestFormat?: "form" | "json";
626627
} => ({
627628
slug: String(firstPartyOAuthClientSlug(config.name)),
@@ -858,7 +859,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
858859
});
859860
}
860861
yield* validateClientEndpoints(input, deps.endpointUrlPolicy);
861-
if (input.tokenEndpointAuthMethod === "basic" && input.clientSecret.length === 0) {
862+
if (
863+
input.tokenEndpointAuthMethod !== undefined &&
864+
input.tokenEndpointAuthMethod !== "body" &&
865+
input.clientSecret.length === 0
866+
) {
862867
return yield* new StorageError({
863868
message: "HTTP Basic token endpoint authentication requires a client secret.",
864869
cause: undefined,

packages/react/src/api/atoms.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type OAuthGrant,
1313
type Owner,
1414
type ProviderItemId,
15+
type TokenEndpointAuthMethod,
1516
type ToolAddress,
1617
} from "@executor-js/sdk/shared";
1718
import * as Atom from "effect/unstable/reactivity/Atom";
@@ -571,7 +572,7 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe(
571572
readonly tokenUrl: string;
572573
readonly grant: OAuthGrant;
573574
readonly clientId: string;
574-
readonly tokenEndpointAuthMethod?: "body" | "basic";
575+
readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
575576
readonly resource?: string | null;
576577
readonly originIntegration?: IntegrationSlug | null;
577578
};

0 commit comments

Comments
 (0)