* Support raw OAuth Basic credentials * Fix OAuth app E2E selector * Stabilize cloud session cap E2E
Unified · Split
| @@ -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. | |
| @@ -147,10 +147,12 @@ scenario( · 146 unmodified lines | ||
| 147 | 147 | const openedSessionIds: string[] = []; |
| 148 | 148 | |
| 149 | 149 | 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. | |
| 154 | 156 | const sessionIds = yield* Effect.forEach( |
| 155 | 157 | Array.from({ length: SESSIONS_TO_OPEN }, (_, index) => index), |
| 156 | 158 | (index) => |
| @@ -159,7 +161,7 @@ scenario( · 2 unmodified lines | ||
| 159 | 161 | openedSessionIds.push(sessionId); |
| 160 | 162 | }), |
| 161 | 163 | ), |
| 162 | − { concurrency: 8 }, | |
| 164 | + { concurrency: 1 }, | |
| 163 | 165 | ); |
| 164 | 166 | |
| 165 | 167 | expect(sessionIds.length, "every session opened").toBe(SESSIONS_TO_OPEN); |
| @@ -96,7 +96,7 @@ scenario( · 95 unmodified lines | ||
| 96 | 96 | await page.locator("#oauth-app-name").fill(appName); |
| 97 | 97 | await page.locator("#oauth-client-id").fill("client-one"); |
| 98 | 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 | 100 | await page.getByRole("button", { name: "Register app", exact: true }).click(); |
| 101 | 101 | // Back on the picker, the new app is selectable AND manageable — |
| 102 | 102 | // the per-app actions menu is what replaced the old apps page. |
| @@ -112,7 +112,9 @@ scenario( · 9 unmodified lines | ||
| 112 | 112 | "the edit form prefills the stored client id", |
| 113 | 113 | ).toBe("client-one"); |
| 114 | 114 | 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 | 118 | .toBe(true); |
| 117 | 119 | }); |
| 118 | 120 | |
| @@ -135,6 +135,7 @@ import { · 134 unmodified lines | ||
| 135 | 135 | isFirstPartyOAuthClientSlug, |
| 136 | 136 | parseStoredTokenEndpointAuthMethod, |
| 137 | 137 | type OAuthService, |
| 138 | + type TokenEndpointAuthMethod, | |
| 138 | 139 | } from "./oauth-client"; |
| 139 | 140 | import type { FirstPartyOAuthClientConfig } from "./oauth-client"; |
| 140 | 141 | import { |
| @@ -2146,7 +2147,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2005 unmodified lines | ||
| 2146 | 2147 | readonly tokenUrl: string; |
| 2147 | 2148 | readonly grant: string; |
| 2148 | 2149 | readonly resource: string | null; |
| 2149 | − readonly tokenEndpointAuthMethod?: "body" | "basic"; | |
| 2150 | + readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; | |
| 2150 | 2151 | readonly tokenRequestFormat?: "form" | "json"; |
| 2151 | 2152 | } |
| 2152 | 2153 | |
| @@ -39,15 +39,19 @@ export const DEFAULT_SUBJECT_TOKEN_TYPE: SubjectTokenType = · 38 unmodified lines | ||
| 39 | 39 | "urn:ietf:params:oauth:token-type:id_token"; |
| 40 | 40 | |
| 41 | 41 | /** 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({ | |
| 43 | 47 | identifier: "TokenEndpointAuthMethod", |
| 44 | 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 | }); |
| 47 | 51 | export type TokenEndpointAuthMethod = typeof TokenEndpointAuthMethodSchema.Type; |
| 48 | 52 | |
| 49 | 53 | export const isTokenEndpointAuthMethod = (value: unknown): value is TokenEndpointAuthMethod => |
| 50 | − value === "body" || value === "basic"; | |
| 54 | + value === "body" || value === "basic" || value === "basic_raw"; | |
| 51 | 55 | |
| 52 | 56 | /** Decode a nullable stored value. `undefined` is the legacy/default body |
| 53 | 57 | * method; `null` means the row contains an invalid non-null value. */ |
| @@ -221,7 +225,9 @@ export interface FirstPartyOAuthClientConfig { · 167 unmodified lines | ||
| 221 | 225 | * them. */ |
| 222 | 226 | readonly authorizationExtraParams?: Readonly<Record<string, string>>; |
| 223 | 227 | /** 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 | 231 | readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; |
| 226 | 232 | /** Token endpoint request encoding. OAuth defaults to URL-encoded form; |
| 227 | 233 | * providers such as Atlassian, ClickUp, and Notion require JSON. */ |
| @@ -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 | 392 | it.effect("carries the URL org selector in provider state without changing redirect_uri", () => |
| 331 | 393 | Effect.scoped( |
| 332 | 394 | Effect.gen(function* () { |
| @@ -312,7 +312,7 @@ describe("exchangeAuthorizationCode", () => { · 311 unmodified lines | ||
| 312 | 312 | yield* exchangeAuthorizationCode({ |
| 313 | 313 | tokenUrl, |
| 314 | 314 | clientId: "cid", |
| 315 | − clientSecret: "csecret", | |
| 315 | + clientSecret: "c-secret", | |
| 316 | 316 | redirectUrl: "https://app.example.com/cb", |
| 317 | 317 | codeVerifier: "verifier", |
| 318 | 318 | code: "abc", |
| @@ -321,7 +321,7 @@ describe("exchangeAuthorizationCode", () => { · 2 unmodified lines | ||
| 321 | 321 | }); |
| 322 | 322 | const call = (yield* calls)[0]!; |
| 323 | 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 | 325 | expect(call.jsonBody).toEqual({ |
| 326 | 326 | grant_type: "authorization_code", |
| 327 | 327 | code: "abc", |
| @@ -811,14 +811,37 @@ describe("exchangeAuthorizationCode", () => { · 483 unmodified lines | ||
| 811 | 811 | yield* exchangeAuthorizationCode({ |
| 812 | 812 | tokenUrl, |
| 813 | 813 | clientId: "cid", |
| 814 | − clientSecret: "csecret", | |
| 814 | + clientSecret: "c-secret", | |
| 815 | 815 | redirectUrl: "https://app.example.com/cb", |
| 816 | 816 | codeVerifier: "verifier", |
| 817 | 817 | code: "abc", |
| 818 | 818 | clientAuth: "basic", |
| 819 | 819 | }); |
| 820 | 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 | 845 | expect(call.headers["authorization"]).toBe(expected); |
| 823 | 846 | expect(call.body.has("client_id")).toBe(false); |
| 824 | 847 | expect(call.body.has("client_secret")).toBe(false); |
| @@ -19,7 +19,7 @@ · 18 unmodified lines | ||
| 19 | 19 | import { Data, Effect, Option, Predicate, Schema } from "effect"; |
| 20 | 20 | import * as oauth from "oauth4webapi"; |
| 21 | 21 | |
| 22 | −import type { SubjectTokenType } from "./oauth-client"; | |
| 22 | +import type { SubjectTokenType, TokenEndpointAuthMethod } from "./oauth-client"; | |
| 23 | 23 | |
| 24 | 24 | // --------------------------------------------------------------------------- |
| 25 | 25 | // Errors |
| @@ -842,7 +842,7 @@ const hostnameForTelemetry = (url: string): string => URL.parse(url)?.hostname ? · 816 unmodified lines | ||
| 842 | 842 | // oauth4webapi adapter helpers |
| 843 | 843 | // --------------------------------------------------------------------------- |
| 844 | 844 | |
| 845 | −export type ClientAuthMethod = "body" | "basic"; | |
| 845 | +export type ClientAuthMethod = TokenEndpointAuthMethod; | |
| 846 | 846 | |
| 847 | 847 | /** |
| 848 | 848 | * 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 | 850 | * method our DCR registers (`token_endpoint_auth_method: client_secret_post`) |
| 851 | 851 | * and the one every confidential client in the v2 model uses. EXPLICIT and |
| 852 | 852 | * 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()`. | |
| 855 | 857 | */ |
| 856 | 858 | export const DEFAULT_CLIENT_AUTH_METHOD: ClientAuthMethod = "body"; |
| 857 | 859 | |
| @@ -914,15 +916,29 @@ const oauth4webapiRequestOptions = ( · 56 unmodified lines | ||
| 914 | 916 | // (public PKCE — `None()`, RFC 7636). This is not a silent guess: `loadClient` |
| 915 | 917 | // persists a non-empty secret for confidential clients and null/"" for public |
| 916 | 918 | // 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 | + | |
| 918 | 934 | const pickClientAuth = ( |
| 919 | 935 | clientSecret: string | null | undefined, |
| 920 | 936 | method: ClientAuthMethod, |
| 921 | 937 | ): oauth.ClientAuth => { |
| 922 | 938 | 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); | |
| 926 | 942 | }; |
| 927 | 943 | |
| 928 | 944 | const normalizedTokenScope = ( |
| @@ -1122,13 +1138,6 @@ export type ExchangeAuthorizationCodeInput = { · 193 unmodified lines | ||
| 1122 | 1138 | readonly fetch?: typeof globalThis.fetch; |
| 1123 | 1139 | }; |
| 1124 | 1140 | |
| 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 | − | |
| 1132 | 1141 | const jsonTokenEndpointRequest = async (input: { |
| 1133 | 1142 | readonly tokenUrl: string; |
| 1134 | 1143 | readonly clientId: string; |
| @@ -1149,21 +1158,24 @@ const jsonTokenEndpointRequest = async (input: { · 14 unmodified lines | ||
| 1149 | 1158 | accept: "application/json", |
| 1150 | 1159 | "content-type": "application/json", |
| 1151 | 1160 | }); |
| 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, | |
| 1157 | 1169 | ); |
| 1158 | 1170 | } |
| 1159 | 1171 | const body = { |
| 1160 | 1172 | grant_type: input.grantType, |
| 1161 | 1173 | ...input.parameters, |
| 1162 | − ...(confidential && input.clientAuth === "basic" | |
| 1174 | + ...(confidential && input.clientAuth !== "body" | |
| 1163 | 1175 | ? {} |
| 1164 | 1176 | : { |
| 1165 | 1177 | client_id: input.clientId, |
| 1166 | − ...(confidential ? { client_secret: input.clientSecret ?? "" } : {}), | |
| 1178 | + ...(confidential ? { client_secret: clientSecret } : {}), | |
| 1167 | 1179 | }), |
| 1168 | 1180 | }; |
| 1169 | 1181 | // oxlint-disable-next-line executor/no-raw-fetch -- boundary: provider token exchange is the SDK's HTTP boundary and preserves its injected fetch seam |
| @@ -55,6 +55,7 @@ import { · 54 unmodified lines | ||
| 55 | 55 | type OAuthStartInput, |
| 56 | 56 | type RegisterDynamicClientInput, |
| 57 | 57 | type SubjectTokenType, |
| 58 | + type TokenEndpointAuthMethod, | |
| 58 | 59 | } from "./oauth-client"; |
| 59 | 60 | import type { OwnerBinding } from "./plugin"; |
| 60 | 61 | import type { CredentialProvider } from "./provider"; |
| @@ -517,7 +518,7 @@ interface LoadedOAuthClient { · 456 unmodified lines | ||
| 517 | 518 | /** Resolved literal secret (read from the provider via the stored item id). */ |
| 518 | 519 | readonly clientSecret: string; |
| 519 | 520 | readonly resource: string | null; |
| 520 | − readonly tokenEndpointAuthMethod?: "body" | "basic"; | |
| 521 | + readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; | |
| 521 | 522 | readonly tokenRequestFormat?: "form" | "json"; |
| 522 | 523 | } |
| 523 | 524 | |
| @@ -621,7 +622,7 @@ export const loadedFirstPartyClient = ( · 97 unmodified lines | ||
| 621 | 622 | readonly clientId: string; |
| 622 | 623 | readonly clientSecret: string; |
| 623 | 624 | readonly resource: string | null; |
| 624 | − readonly tokenEndpointAuthMethod?: "body" | "basic"; | |
| 625 | + readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; | |
| 625 | 626 | readonly tokenRequestFormat?: "form" | "json"; |
| 626 | 627 | } => ({ |
| 627 | 628 | slug: String(firstPartyOAuthClientSlug(config.name)), |
| @@ -858,7 +859,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 230 unmodified lines | ||
| 858 | 859 | }); |
| 859 | 860 | } |
| 860 | 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 | 867 | return yield* new StorageError({ |
| 863 | 868 | message: "HTTP Basic token endpoint authentication requires a client secret.", |
| 864 | 869 | cause: undefined, |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | type OAuthGrant, |
| 13 | 13 | type Owner, |
| 14 | 14 | type ProviderItemId, |
| 15 | + type TokenEndpointAuthMethod, | |
| 15 | 16 | type ToolAddress, |
| 16 | 17 | } from "@executor-js/sdk/shared"; |
| 17 | 18 | import * as Atom from "effect/unstable/reactivity/Atom"; |
| @@ -571,7 +572,7 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( · 553 unmodified lines | ||
| 571 | 572 | readonly tokenUrl: string; |
| 572 | 573 | readonly grant: OAuthGrant; |
| 573 | 574 | readonly clientId: string; |
| 574 | − readonly tokenEndpointAuthMethod?: "body" | "basic"; | |
| 575 | + readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; | |
| 575 | 576 | readonly resource?: string | null; |
| 576 | 577 | readonly originIntegration?: IntegrationSlug | null; |
| 577 | 578 | }; |
| @@ -124,6 +124,17 @@ describe("canSubmitOAuthClientForm", () => { · 123 unmodified lines | ||
| 124 | 124 | }), |
| 125 | 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 | |
| 129 | 140 | describe("preferredManualTokenEndpointAuthMethod", () => { |
| @@ -116,7 +116,9 @@ export const canSubmitOAuthClientForm = (input: { · 115 unmodified lines | ||
| 116 | 116 | input.name.trim().length > 0 && |
| 117 | 117 | input.clientId.trim().length > 0 && |
| 118 | 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 | 122 | input.tokenUrl.trim().length > 0 && |
| 121 | 123 | (input.grant === "client_credentials" || input.authorizationUrl.trim().length > 0); |
| 122 | 124 | |
| @@ -578,7 +580,7 @@ export function OAuthClientForm(props: { · 455 unmodified lines | ||
| 578 | 580 | onValueChange={(next: string) => { |
| 579 | 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 | 594 | label: "HTTP Basic", |
| 593 | 595 | hint: "client_secret_basic", |
| 594 | 596 | }, |
| 597 | + { | |
| 598 | + value: "basic_raw", | |
| 599 | + label: "HTTP Basic (raw)", | |
| 600 | + hint: "provider compatibility", | |
| 601 | + }, | |
| 595 | 602 | ] as const |
| 596 | 603 | ).map((option) => ( |
| 597 | 604 | <Label |
| @@ -611,7 +618,7 @@ export function OAuthClientForm(props: { · 13 unmodified lines | ||
| 611 | 618 | </Label> |
| 612 | 619 | ))} |
| 613 | 620 | </RadioGroup> |
| 614 | − {tokenEndpointAuthMethod === "basic" && clientSecret.trim().length === 0 ? ( | |
| 621 | + {tokenEndpointAuthMethod !== "body" && clientSecret.trim().length === 0 ? ( | |
| 615 | 622 | <p className="text-xs text-destructive">HTTP Basic requires a client secret.</p> |
| 616 | 623 | ) : null} |
| 617 | 624 | </div> |