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
5Add 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
147 const openedSessionIds: string[] = [];147 const openedSessionIds: string[] = [];
148 148
149 const scenarioBody = Effect.gen(function* () {149 const scenarioBody = Effect.gen(function* () {
150 // Open more sessions than the cap allows, at limited concurrency. None150 // Open more sessions than the cap allows. Keep admission sequential:
151 // of them run any work, so every one is immediately eviction-eligible —151 // the cloud e2e database is one serialized PGlite instance, and this
152 // crossing the cap must pick at least one and tear it down through its152 // scenario exercises resident eviction rather than concurrent cold
153 // own stub.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.
154 const sessionIds = yield* Effect.forEach(156 const sessionIds = yield* Effect.forEach(
155 Array.from({ length: SESSIONS_TO_OPEN }, (_, index) => index),157 Array.from({ length: SESSIONS_TO_OPEN }, (_, index) => index),
156 (index) =>158 (index) =>
@@ -159,7 +161,7 @@ scenario( · 2 unmodified lines
159 openedSessionIds.push(sessionId);161 openedSessionIds.push(sessionId);
160 }),162 }),
161 ),163 ),
162 { concurrency: 8 },164 { concurrency: 1 },
163 );165 );
164 166
165 expect(sessionIds.length, "every session opened").toBe(SESSIONS_TO_OPEN);167 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
96 await page.locator("#oauth-app-name").fill(appName);96 await page.locator("#oauth-app-name").fill(appName);
97 await page.locator("#oauth-client-id").fill("client-one");97 await page.locator("#oauth-client-id").fill("client-one");
98 await page.locator("#oauth-client-secret").fill("secret-one");98 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();
100 await page.getByRole("button", { name: "Register app", exact: true }).click();100 await page.getByRole("button", { name: "Register app", exact: true }).click();
101 // Back on the picker, the new app is selectable AND manageable —101 // Back on the picker, the new app is selectable AND manageable —
102 // the per-app actions menu is what replaced the old apps page.102 // the per-app actions menu is what replaced the old apps page.
@@ -112,7 +112,9 @@ scenario( · 9 unmodified lines
112 "the edit form prefills the stored client id",112 "the edit form prefills the stored client id",
113 ).toBe("client-one");113 ).toBe("client-one");
114 await expect114 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 )
116 .toBe(true);118 .toBe(true);
117 });119 });
118 120
packages/core/sdk/src/executor.ts+2−1
@@ -135,6 +135,7 @@ import { · 134 unmodified lines
135 isFirstPartyOAuthClientSlug,135 isFirstPartyOAuthClientSlug,
136 parseStoredTokenEndpointAuthMethod,136 parseStoredTokenEndpointAuthMethod,
137 type OAuthService,137 type OAuthService,
138 type TokenEndpointAuthMethod,
138} from "./oauth-client";139} from "./oauth-client";
139import type { FirstPartyOAuthClientConfig } from "./oauth-client";140import type { FirstPartyOAuthClientConfig } from "./oauth-client";
140import {141import {
@@ -2146,7 +2147,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2005 unmodified lines
2146 readonly tokenUrl: string;2147 readonly tokenUrl: string;
2147 readonly grant: string;2148 readonly grant: string;
2148 readonly resource: string | null;2149 readonly resource: string | null;
2149 readonly tokenEndpointAuthMethod?: "body" | "basic";2150 readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
2150 readonly tokenRequestFormat?: "form" | "json";2151 readonly tokenRequestFormat?: "form" | "json";
2151 }2152 }
2152 2153
packages/core/sdk/src/oauth-client.ts+10−4
@@ -39,15 +39,19 @@ export const DEFAULT_SUBJECT_TOKEN_TYPE: SubjectTokenType = · 38 unmodified lines
39 "urn:ietf:params:oauth:token-type:id_token";39 "urn:ietf:params:oauth:token-type:id_token";
40 40
41/** How a confidential OAuth client authenticates to its token endpoint. */41/** How a confidential OAuth client authenticates to its token endpoint. */
42export const TokenEndpointAuthMethodSchema = Schema.Literals(["body", "basic"]).annotate({42export const TokenEndpointAuthMethodSchema = Schema.Literals([
43 "body",
44 "basic",
45 "basic_raw",
46]).annotate({
43 identifier: "TokenEndpointAuthMethod",47 identifier: "TokenEndpointAuthMethod",
44 description:48 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.",
46});50});
47export type TokenEndpointAuthMethod = typeof TokenEndpointAuthMethodSchema.Type;51export type TokenEndpointAuthMethod = typeof TokenEndpointAuthMethodSchema.Type;
48 52
49export const isTokenEndpointAuthMethod = (value: unknown): value is TokenEndpointAuthMethod =>53export const isTokenEndpointAuthMethod = (value: unknown): value is TokenEndpointAuthMethod =>
50 value === "body" || value === "basic";54 value === "body" || value === "basic" || value === "basic_raw";
51 55
52/** Decode a nullable stored value. `undefined` is the legacy/default body56/** Decode a nullable stored value. `undefined` is the legacy/default body
53 * method; `null` means the row contains an invalid non-null value. */57 * method; `null` means the row contains an invalid non-null value. */
@@ -221,7 +225,9 @@ export interface FirstPartyOAuthClientConfig { · 167 unmodified lines
221 * them. */225 * them. */
222 readonly authorizationExtraParams?: Readonly<Record<string, string>>;226 readonly authorizationExtraParams?: Readonly<Record<string, string>>;
223 /** Token endpoint client-auth transport. Omitted means227 /** 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. */
225 readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod;231 readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
226 /** Token endpoint request encoding. OAuth defaults to URL-encoded form;232 /** Token endpoint request encoding. OAuth defaults to URL-encoded form;
227 * providers such as Atlassian, ClickUp, and Notion require JSON. */233 * 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
327 ),327 ),
328 );328 );
329 329
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
330 it.effect("carries the URL org selector in provider state without changing redirect_uri", () =>392 it.effect("carries the URL org selector in provider state without changing redirect_uri", () =>
331 Effect.scoped(393 Effect.scoped(
332 Effect.gen(function* () {394 Effect.gen(function* () {
packages/core/sdk/src/oauth-helpers.test.ts+27−4
@@ -312,7 +312,7 @@ describe("exchangeAuthorizationCode", () => { · 311 unmodified lines
312 yield* exchangeAuthorizationCode({312 yield* exchangeAuthorizationCode({
313 tokenUrl,313 tokenUrl,
314 clientId: "cid",314 clientId: "cid",
315 clientSecret: "csecret",315 clientSecret: "c-secret",
316 redirectUrl: "https://app.example.com/cb",316 redirectUrl: "https://app.example.com/cb",
317 codeVerifier: "verifier",317 codeVerifier: "verifier",
318 code: "abc",318 code: "abc",
@@ -321,7 +321,7 @@ describe("exchangeAuthorizationCode", () => { · 2 unmodified lines
321 });321 });
322 const call = (yield* calls)[0]!;322 const call = (yield* calls)[0]!;
323 expect(call.headers["content-type"]).toBe("application/json");323 expect(call.headers["content-type"]).toBe("application/json");
324 expect(call.headers["authorization"]).toBe("Basic Y2lkOmNzZWNyZXQ=");324 expect(call.headers["authorization"]).toBe("Basic Y2lkOmMlMkRzZWNyZXQ=");
325 expect(call.jsonBody).toEqual({325 expect(call.jsonBody).toEqual({
326 grant_type: "authorization_code",326 grant_type: "authorization_code",
327 code: "abc",327 code: "abc",
@@ -811,14 +811,37 @@ describe("exchangeAuthorizationCode", () => { · 483 unmodified lines
811 yield* exchangeAuthorizationCode({811 yield* exchangeAuthorizationCode({
812 tokenUrl,812 tokenUrl,
813 clientId: "cid",813 clientId: "cid",
814 clientSecret: "csecret",814 clientSecret: "c-secret",
815 redirectUrl: "https://app.example.com/cb",815 redirectUrl: "https://app.example.com/cb",
816 codeVerifier: "verifier",816 codeVerifier: "verifier",
817 code: "abc",817 code: "abc",
818 clientAuth: "basic",818 clientAuth: "basic",
819 });819 });
820 const call = (yield* calls)[0]!;820 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")}`;
822 expect(call.headers["authorization"]).toBe(expected);845 expect(call.headers["authorization"]).toBe(expected);
823 expect(call.body.has("client_id")).toBe(false);846 expect(call.body.has("client_id")).toBe(false);
824 expect(call.body.has("client_secret")).toBe(false);847 expect(call.body.has("client_secret")).toBe(false);
packages/core/sdk/src/oauth-helpers.ts+34−22
@@ -19,7 +19,7 @@ · 18 unmodified lines
19import { Data, Effect, Option, Predicate, Schema } from "effect";19import { Data, Effect, Option, Predicate, Schema } from "effect";
20import * as oauth from "oauth4webapi";20import * as oauth from "oauth4webapi";
21 21
22import type { SubjectTokenType } from "./oauth-client";22import type { SubjectTokenType, TokenEndpointAuthMethod } from "./oauth-client";
23 23
24// ---------------------------------------------------------------------------24// ---------------------------------------------------------------------------
25// Errors25// Errors
@@ -842,7 +842,7 @@ const hostnameForTelemetry = (url: string): string => URL.parse(url)?.hostname ? · 816 unmodified lines
842// oauth4webapi adapter helpers842// oauth4webapi adapter helpers
843// ---------------------------------------------------------------------------843// ---------------------------------------------------------------------------
844 844
845export type ClientAuthMethod = "body" | "basic";845export type ClientAuthMethod = TokenEndpointAuthMethod;
846 846
847/**847/**
848 * The token-endpoint client-auth transport used when a caller doesn't specify848 * 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
850 * method our DCR registers (`token_endpoint_auth_method: client_secret_post`)850 * method our DCR registers (`token_endpoint_auth_method: client_secret_post`)
851 * and the one every confidential client in the v2 model uses. EXPLICIT and851 * and the one every confidential client in the v2 model uses. EXPLICIT and
852 * documented rather than a hidden inline `?? "body"`: callers that need852 * documented rather than a hidden inline `?? "body"`: callers that need
853 * `client_secret_basic` pass `clientAuth: "basic"`. For PUBLIC clients (no853 * `client_secret_basic` pass `clientAuth: "basic"`. Providers that reject the
854 * secret) the method is irrelevant — `pickClientAuth` returns `None()`.854 * RFC form encoding can explicitly pass `clientAuth: "basic_raw"`. For PUBLIC
855 * clients (no secret) the method is irrelevant — `pickClientAuth` returns
856 * `None()`.
855 */857 */
856export const DEFAULT_CLIENT_AUTH_METHOD: ClientAuthMethod = "body";858export const DEFAULT_CLIENT_AUTH_METHOD: ClientAuthMethod = "body";
857 859
@@ -914,15 +916,29 @@ const oauth4webapiRequestOptions = ( · 56 unmodified lines
914// (public PKCE — `None()`, RFC 7636). This is not a silent guess: `loadClient`916// (public PKCE — `None()`, RFC 7636). This is not a silent guess: `loadClient`
915// persists a non-empty secret for confidential clients and null/"" for public917// persists a non-empty secret for confidential clients and null/"" for public
916// ones, so an absent secret here unambiguously means "public client". The918// 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).
921const 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
928const rawClientSecretBasic =
929 (clientSecret: string): oauth.ClientAuth =>
930 (_authorizationServer, client, _body, headers) => {
931 headers.set("authorization", `Basic ${base64BasicCredentials(client.client_id, clientSecret)}`);
932 };
933
918const pickClientAuth = (934const pickClientAuth = (
919 clientSecret: string | null | undefined,935 clientSecret: string | null | undefined,
920 method: ClientAuthMethod,936 method: ClientAuthMethod,
921): oauth.ClientAuth => {937): oauth.ClientAuth => {
922 if (!clientSecret) return oauth.None();938 if (!clientSecret) return oauth.None();
923 return method === "basic"939 if (method === "basic") return oauth.ClientSecretBasic(clientSecret);
924 ? oauth.ClientSecretBasic(clientSecret)940 if (method === "basic_raw") return rawClientSecretBasic(clientSecret);
925 : oauth.ClientSecretPost(clientSecret);941 return oauth.ClientSecretPost(clientSecret);
926};942};
927 943
928const normalizedTokenScope = (944const normalizedTokenScope = (
@@ -1122,13 +1138,6 @@ export type ExchangeAuthorizationCodeInput = { · 193 unmodified lines
1122 readonly fetch?: typeof globalThis.fetch;1138 readonly fetch?: typeof globalThis.fetch;
1123};1139};
1124 1140
1125const 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
1132const jsonTokenEndpointRequest = async (input: {1141const jsonTokenEndpointRequest = async (input: {
1133 readonly tokenUrl: string;1142 readonly tokenUrl: string;
1134 readonly clientId: string;1143 readonly clientId: string;
@@ -1149,21 +1158,24 @@ const jsonTokenEndpointRequest = async (input: { · 14 unmodified lines
1149 accept: "application/json",1158 accept: "application/json",
1150 "content-type": "application/json",1159 "content-type": "application/json",
1151 });1160 });
1152 const confidential = Boolean(input.clientSecret);1161 const clientSecret = input.clientSecret ?? "";
1153 if (confidential && input.clientAuth === "basic") {1162 const confidential = clientSecret.length > 0;
1154 headers.set(1163 if (confidential && input.clientAuth !== "body") {
1155 "authorization",1164 await pickClientAuth(clientSecret, input.clientAuth)(
1156 `Basic ${base64BasicCredentials(input.clientId, input.clientSecret ?? "")}`,1165 asFromTokenUrl(tokenUrl, input.endpointUrlPolicy),
1166 { client_id: input.clientId },
1167 new URLSearchParams(),
1168 headers,
1157 );1169 );
1158 }1170 }
1159 const body = {1171 const body = {
1160 grant_type: input.grantType,1172 grant_type: input.grantType,
1161 ...input.parameters,1173 ...input.parameters,
1162 ...(confidential && input.clientAuth === "basic"1174 ...(confidential && input.clientAuth !== "body"
1163 ? {}1175 ? {}
1164 : {1176 : {
1165 client_id: input.clientId,1177 client_id: input.clientId,
1166 ...(confidential ? { client_secret: input.clientSecret ?? "" } : {}),1178 ...(confidential ? { client_secret: clientSecret } : {}),
1167 }),1179 }),
1168 };1180 };
1169 // oxlint-disable-next-line executor/no-raw-fetch -- boundary: provider token exchange is the SDK's HTTP boundary and preserves its injected fetch seam1181 // 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
55 type OAuthStartInput,55 type OAuthStartInput,
56 type RegisterDynamicClientInput,56 type RegisterDynamicClientInput,
57 type SubjectTokenType,57 type SubjectTokenType,
58 type TokenEndpointAuthMethod,
58} from "./oauth-client";59} from "./oauth-client";
59import type { OwnerBinding } from "./plugin";60import type { OwnerBinding } from "./plugin";
60import type { CredentialProvider } from "./provider";61import type { CredentialProvider } from "./provider";
@@ -517,7 +518,7 @@ interface LoadedOAuthClient { · 456 unmodified lines
517 /** Resolved literal secret (read from the provider via the stored item id). */518 /** Resolved literal secret (read from the provider via the stored item id). */
518 readonly clientSecret: string;519 readonly clientSecret: string;
519 readonly resource: string | null;520 readonly resource: string | null;
520 readonly tokenEndpointAuthMethod?: "body" | "basic";521 readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
521 readonly tokenRequestFormat?: "form" | "json";522 readonly tokenRequestFormat?: "form" | "json";
522}523}
523 524
@@ -621,7 +622,7 @@ export const loadedFirstPartyClient = ( · 97 unmodified lines
621 readonly clientId: string;622 readonly clientId: string;
622 readonly clientSecret: string;623 readonly clientSecret: string;
623 readonly resource: string | null;624 readonly resource: string | null;
624 readonly tokenEndpointAuthMethod?: "body" | "basic";625 readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
625 readonly tokenRequestFormat?: "form" | "json";626 readonly tokenRequestFormat?: "form" | "json";
626} => ({627} => ({
627 slug: String(firstPartyOAuthClientSlug(config.name)),628 slug: String(firstPartyOAuthClientSlug(config.name)),
@@ -858,7 +859,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 230 unmodified lines
858 });859 });
859 }860 }
860 yield* validateClientEndpoints(input, deps.endpointUrlPolicy);861 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 ) {
862 return yield* new StorageError({867 return yield* new StorageError({
863 message: "HTTP Basic token endpoint authentication requires a client secret.",868 message: "HTTP Basic token endpoint authentication requires a client secret.",
864 cause: undefined,869 cause: undefined,
packages/react/src/api/atoms.tsx+2−1
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
12 type OAuthGrant,12 type OAuthGrant,
13 type Owner,13 type Owner,
14 type ProviderItemId,14 type ProviderItemId,
15 type TokenEndpointAuthMethod,
15 type ToolAddress,16 type ToolAddress,
16} from "@executor-js/sdk/shared";17} from "@executor-js/sdk/shared";
17import * as Atom from "effect/unstable/reactivity/Atom";18import * as Atom from "effect/unstable/reactivity/Atom";
@@ -571,7 +572,7 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( · 553 unmodified lines
571 readonly tokenUrl: string;572 readonly tokenUrl: string;
572 readonly grant: OAuthGrant;573 readonly grant: OAuthGrant;
573 readonly clientId: string;574 readonly clientId: string;
574 readonly tokenEndpointAuthMethod?: "body" | "basic";575 readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
575 readonly resource?: string | null;576 readonly resource?: string | null;
576 readonly originIntegration?: IntegrationSlug | null;577 readonly originIntegration?: IntegrationSlug | null;
577 };578 };
packages/react/src/components/oauth-client-form.test.ts+11−0
@@ -124,6 +124,17 @@ describe("canSubmitOAuthClientForm", () => { · 123 unmodified lines
124 }),124 }),
125 ).toBe(false);125 ).toBe(false);
126 });126 });
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 });
127});138});
128 139
129describe("preferredManualTokenEndpointAuthMethod", () => {140describe("preferredManualTokenEndpointAuthMethod", () => {
packages/react/src/components/oauth-client-form.tsx+10−3
@@ -116,7 +116,9 @@ export const canSubmitOAuthClientForm = (input: { · 115 unmodified lines
116 input.name.trim().length > 0 &&116 input.name.trim().length > 0 &&
117 input.clientId.trim().length > 0 &&117 input.clientId.trim().length > 0 &&
118 (input.grant === "authorization_code" || input.clientSecret.trim().length > 0) &&118 (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) &&
120 input.tokenUrl.trim().length > 0 &&122 input.tokenUrl.trim().length > 0 &&
121 (input.grant === "client_credentials" || input.authorizationUrl.trim().length > 0);123 (input.grant === "client_credentials" || input.authorizationUrl.trim().length > 0);
122 124
@@ -578,7 +580,7 @@ export function OAuthClientForm(props: { · 455 unmodified lines
578 onValueChange={(next: string) => {580 onValueChange={(next: string) => {
579 if (isTokenEndpointAuthMethod(next)) setTokenEndpointAuthMethod(next);581 if (isTokenEndpointAuthMethod(next)) setTokenEndpointAuthMethod(next);
580 }}582 }}
581 className="grid gap-2 sm:grid-cols-2"583 className="grid gap-2 sm:grid-cols-3"
582 >584 >
583 {(585 {(
584 [586 [
@@ -592,6 +594,11 @@ export function OAuthClientForm(props: { · 7 unmodified lines
592 label: "HTTP Basic",594 label: "HTTP Basic",
593 hint: "client_secret_basic",595 hint: "client_secret_basic",
594 },596 },
597 {
598 value: "basic_raw",
599 label: "HTTP Basic (raw)",
600 hint: "provider compatibility",
601 },
595 ] as const602 ] as const
596 ).map((option) => (603 ).map((option) => (
597 <Label604 <Label
@@ -611,7 +618,7 @@ export function OAuthClientForm(props: { · 13 unmodified lines
611 </Label>618 </Label>
612 ))}619 ))}
613 </RadioGroup>620 </RadioGroup>
614 {tokenEndpointAuthMethod === "basic" && clientSecret.trim().length === 0 ? (621 {tokenEndpointAuthMethod !== "body" && clientSecret.trim().length === 0 ? (
615 <p className="text-xs text-destructive">HTTP Basic requires a client secret.</p>622 <p className="text-xs text-destructive">HTTP Basic requires a client secret.</p>
616 ) : null}623 ) : null}
617 </div>624 </div>