* 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 | 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. None | 150 | // 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 its | 152 | // 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); |
| @@ -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 expect | 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 | .toBe(true); | 118 | .toBe(true); |
| 117 | }); | 119 | }); |
| 118 | 120 | ||
| @@ -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"; |
| 139 | import type { FirstPartyOAuthClientConfig } from "./oauth-client"; | 140 | import type { FirstPartyOAuthClientConfig } from "./oauth-client"; |
| 140 | import { | 141 | import { |
| @@ -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 | ||
| @@ -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. */ |
| 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 | 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 | }); |
| 47 | export type TokenEndpointAuthMethod = typeof TokenEndpointAuthMethodSchema.Type; | 51 | export type TokenEndpointAuthMethod = typeof TokenEndpointAuthMethodSchema.Type; |
| 48 | 52 | ||
| 49 | export const isTokenEndpointAuthMethod = (value: unknown): value is TokenEndpointAuthMethod => | 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 | /** Decode a nullable stored value. `undefined` is the legacy/default body | 56 | /** 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 means | 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 | 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. */ |
| @@ -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* () { |
| @@ -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); |
| @@ -19,7 +19,7 @@ · 18 unmodified lines | |||
| 19 | import { Data, Effect, Option, Predicate, Schema } from "effect"; | 19 | import { Data, Effect, Option, Predicate, Schema } from "effect"; |
| 20 | import * as oauth from "oauth4webapi"; | 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 | // Errors | 25 | // Errors |
| @@ -842,7 +842,7 @@ const hostnameForTelemetry = (url: string): string => URL.parse(url)?.hostname ? · 816 unmodified lines | |||
| 842 | // oauth4webapi adapter helpers | 842 | // oauth4webapi adapter helpers |
| 843 | // --------------------------------------------------------------------------- | 843 | // --------------------------------------------------------------------------- |
| 844 | 844 | ||
| 845 | export type ClientAuthMethod = "body" | "basic"; | 845 | export type ClientAuthMethod = TokenEndpointAuthMethod; |
| 846 | 846 | ||
| 847 | /** | 847 | /** |
| 848 | * The token-endpoint client-auth transport used when a caller doesn't specify | 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 | * 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 and | 851 | * and the one every confidential client in the v2 model uses. EXPLICIT and |
| 852 | * documented rather than a hidden inline `?? "body"`: callers that need | 852 | * documented rather than a hidden inline `?? "body"`: callers that need |
| 853 | * `client_secret_basic` pass `clientAuth: "basic"`. For PUBLIC clients (no | 853 | * `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 | */ |
| 856 | export const DEFAULT_CLIENT_AUTH_METHOD: ClientAuthMethod = "body"; | 858 | export 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 public | 917 | // persists a non-empty secret for confidential clients and null/"" for public |
| 916 | // ones, so an absent secret here unambiguously means "public client". The | 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 | const pickClientAuth = ( | 934 | const 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 | ||
| 928 | const normalizedTokenScope = ( | 944 | const 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 | ||
| 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 | const jsonTokenEndpointRequest = async (input: { | 1141 | const 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 seam | 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 | 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"; |
| 59 | import type { OwnerBinding } from "./plugin"; | 60 | import type { OwnerBinding } from "./plugin"; |
| 60 | import type { CredentialProvider } from "./provider"; | 61 | import 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, |
| @@ -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"; |
| 17 | import * as Atom from "effect/unstable/reactivity/Atom"; | 18 | import * 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 | }; |
| @@ -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 | ||
| 129 | describe("preferredManualTokenEndpointAuthMethod", () => { | 140 | describe("preferredManualTokenEndpointAuthMethod", () => { |
| @@ -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 const | 602 | ] as const |
| 596 | ).map((option) => ( | 603 | ).map((option) => ( |
| 597 | <Label | 604 | <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> |