History

Support raw OAuth Basic credentials (#1907)

* Support raw OAuth Basic credentials * Fix OAuth app E2E selector * Stabilize cloud session cap E2E

Rhys Sullivan committed 2 months ago

Commit b5271a61 parent e8590a012 files · +182 −45Browse files

Unified · Split

12 files
  1. .changeset/raw-oauth-basic.md +5 −0
  2. e2e/cloud/mcp-session-cap-eviction.test.ts +7 −5
  3. e2e/selfhost/oauth-app-modal.test.ts +4 −2
  4. packages/core/sdk/src/executor.ts +2 −1
  5. packages/core/sdk/src/oauth-client.ts +10 −4
  6. packages/core/sdk/src/oauth-flow.test.ts +62 −0
  7. packages/core/sdk/src/oauth-helpers.test.ts +27 −4
  8. packages/core/sdk/src/oauth-helpers.ts +34 −22
  9. packages/core/sdk/src/oauth-service.ts +8 −3
  10. packages/react/src/api/atoms.tsx +2 −1
  11. packages/react/src/components/oauth-client-form.test.ts +11 −0
  12. packages/react/src/components/oauth-client-form.tsx +10 −3
.changeset/raw-oauth-basic.md+5−0
@@ -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+7−5
@@ -147,10 +147,12 @@ scenario( · 146 unmodified lines
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( · 2 unmodified lines
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+4−2
@@ -96,7 +96,7 @@ scenario( · 95 unmodified lines
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( · 9 unmodified lines
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+2−1
@@ -135,6 +135,7 @@ import { · 134 unmodified lines
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 · 2005 unmodified lines
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+10−4
@@ -39,15 +39,19 @@ export const DEFAULT_SUBJECT_TOKEN_TYPE: SubjectTokenType = · 38 unmodified lines
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 { · 167 unmodified lines
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+62−0
@@ -327,6 +327,68 @@ describe("oauth.start / oauth.complete", () => { · 326 unmodified lines
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+27−4
@@ -312,7 +312,7 @@ describe("exchangeAuthorizationCode", () => { · 311 unmodified lines
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", () => { · 2 unmodified lines
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", () => { · 483 unmodified lines
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+34−22
@@ -19,7 +19,7 @@ · 18 unmodified lines
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,7 +842,7 @@ const hostnameForTelemetry = (url: string): string => URL.parse(url)?.hostname ? · 816 unmodified lines
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
@@ -850,8 +850,10 @@ export type ClientAuthMethod = "body" | "basic"; · 1 unmodified line
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 = ( · 56 unmodified lines
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 = { · 193 unmodified lines
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: { · 14 unmodified lines
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+8−3
@@ -55,6 +55,7 @@ import { · 54 unmodified lines
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 { · 456 unmodified lines
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 = ( · 97 unmodified lines
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 => { · 230 unmodified lines
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+2−1
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
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( · 553 unmodified lines
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 };
packages/react/src/components/oauth-client-form.test.ts+11−0
@@ -124,6 +124,17 @@ describe("canSubmitOAuthClientForm", () => { · 123 unmodified lines
124124 }),
125125 ).toBe(false);
126126 });
127+
128+ it("requires a secret when raw HTTP Basic is selected", () => {
129+ expect(
130+ canSubmitOAuthClientForm({
131+ ...validBase,
132+ grant: "authorization_code",
133+ clientSecret: "",
134+ tokenEndpointAuthMethod: "basic_raw",
135+ }),
136+ ).toBe(false);
137+ });
127138 });
128139
129140 describe("preferredManualTokenEndpointAuthMethod", () => {
packages/react/src/components/oauth-client-form.tsx+10−3
@@ -116,7 +116,9 @@ export const canSubmitOAuthClientForm = (input: { · 115 unmodified lines
116116 input.name.trim().length > 0 &&
117117 input.clientId.trim().length > 0 &&
118118 (input.grant === "authorization_code" || input.clientSecret.trim().length > 0) &&
119− (input.tokenEndpointAuthMethod !== "basic" || input.clientSecret.trim().length > 0) &&
119+ (input.tokenEndpointAuthMethod === undefined ||
120+ input.tokenEndpointAuthMethod === "body" ||
121+ input.clientSecret.trim().length > 0) &&
120122 input.tokenUrl.trim().length > 0 &&
121123 (input.grant === "client_credentials" || input.authorizationUrl.trim().length > 0);
122124
@@ -578,7 +580,7 @@ export function OAuthClientForm(props: { · 455 unmodified lines
578580 onValueChange={(next: string) => {
579581 if (isTokenEndpointAuthMethod(next)) setTokenEndpointAuthMethod(next);
580582 }}
581− className="grid gap-2 sm:grid-cols-2"
583+ className="grid gap-2 sm:grid-cols-3"
582584 >
583585 {(
584586 [
@@ -592,6 +594,11 @@ export function OAuthClientForm(props: { · 7 unmodified lines
592594 label: "HTTP Basic",
593595 hint: "client_secret_basic",
594596 },
597+ {
598+ value: "basic_raw",
599+ label: "HTTP Basic (raw)",
600+ hint: "provider compatibility",
601+ },
595602 ] as const
596603 ).map((option) => (
597604 <Label
@@ -611,7 +618,7 @@ export function OAuthClientForm(props: { · 13 unmodified lines
611618 </Label>
612619 ))}
613620 </RadioGroup>
614− {tokenEndpointAuthMethod === "basic" && clientSecret.trim().length === 0 ? (
621+ {tokenEndpointAuthMethod !== "body" && clientSecret.trim().length === 0 ? (
615622 <p className="text-xs text-destructive">HTTP Basic requires a client secret.</p>
616623 ) : null}
617624 </div>