From 5c1ff4e6e4e08d1177312211576e2837bb68ad85 Mon Sep 17 00:00:00 2001 From: "airbooks-alpha[bot]" <285858461+airbooks-alpha[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:25:48 -0400 Subject: [PATCH] fix(oauth): keep interoperable Basic as the stored basic method Rebase UsefulSoftwareCo/executor#1448 onto v1.6.8 instead of dropping it. oauth4webapi form-encodes Basic credentials; Aikido compares them literally. Treat both "basic" and "basic_raw" as that interoperable encoding so existing rows and refresh keep working. Co-authored-by: Matt Hodgson --- packages/core/sdk/src/oauth-helpers.test.ts | 24 +++++++++++++++++++-- packages/core/sdk/src/oauth-helpers.ts | 18 ++++++++++------ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index 3b832354c6..7c465f3c79 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -321,7 +321,7 @@ describe("exchangeAuthorizationCode", () => { }); const call = (yield* calls)[0]!; expect(call.headers["content-type"]).toBe("application/json"); - expect(call.headers["authorization"]).toBe("Basic Y2lkOmMlMkRzZWNyZXQ="); + expect(call.headers["authorization"]).toBe("Basic Y2lkOmMtc2VjcmV0"); expect(call.jsonBody).toEqual({ grant_type: "authorization_code", code: "abc", @@ -818,7 +818,7 @@ describe("exchangeAuthorizationCode", () => { clientAuth: "basic", }); const call = (yield* calls)[0]!; - const expected = `Basic ${Buffer.from("cid:c%2Dsecret").toString("base64")}`; + const expected = `Basic ${Buffer.from("cid:c-secret").toString("base64")}`; expect(call.headers["authorization"]).toBe(expected); expect(call.body.has("client_id")).toBe(false); expect(call.body.has("client_secret")).toBe(false); @@ -1312,6 +1312,26 @@ describe("exchangeAuthorizationCode", () => { }); describe("exchangeClientCredentials", () => { + it.effect("uses literal HTTP Basic credentials when clientAuth=basic", () => + withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) => + Effect.gen(function* () { + yield* exchangeClientCredentials({ + tokenUrl, + clientId: "client_id-with-punctuation", + clientSecret: "client_secret-with-punctuation", + clientAuth: "basic", + }); + const call = (yield* calls)[0]!; + const expected = `Basic ${Buffer.from( + "client_id-with-punctuation:client_secret-with-punctuation", + ).toString("base64")}`; + expect(call.headers["authorization"]).toBe(expected); + expect(call.body.has("client_id")).toBe(false); + expect(call.body.has("client_secret")).toBe(false); + }), + ), + ); + it.effect("routes token grant requests through the injected fetch", () => withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl }) => Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index 2f7c39fffc..c4fd301f31 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -850,10 +850,13 @@ export type ClientAuthMethod = TokenEndpointAuthMethod; * method our DCR registers (`token_endpoint_auth_method: client_secret_post`) * and the one every confidential client in the v2 model uses. EXPLICIT and * documented rather than a hidden inline `?? "body"`: callers that need - * `client_secret_basic` pass `clientAuth: "basic"`. Providers that reject the - * RFC form encoding can explicitly pass `clientAuth: "basic_raw"`. For PUBLIC - * clients (no secret) the method is irrelevant — `pickClientAuth` returns - * `None()`. + * `client_secret_basic` pass `clientAuth: "basic"` or `"basic_raw"`. Both send + * the interoperable HTTP Basic representation (Base64 of the literal UTF-8 + * `client_id:client_secret` pair). oauth4webapi's RFC 6749 form-encoding of + * each component before Base64 is not used: providers such as Aikido compare + * the decoded username and password literally, so `_` becoming `%5F` is a + * rejected credential. For PUBLIC clients (no secret) the method is + * irrelevant — `pickClientAuth` returns `None()`. */ export const DEFAULT_CLIENT_AUTH_METHOD: ClientAuthMethod = "body"; @@ -936,8 +939,11 @@ const pickClientAuth = ( method: ClientAuthMethod, ): oauth.ClientAuth => { if (!clientSecret) return oauth.None(); - if (method === "basic") return oauth.ClientSecretBasic(clientSecret); - if (method === "basic_raw") return rawClientSecretBasic(clientSecret); + // `"basic"` and `"basic_raw"` are the same wire format on this fork: the + // interoperable literal pair, not oauth4webapi's RFC-strict form-encoding. + // Stored Aikido apps use `"basic"`; upstream later split a `"basic_raw"` + // alias. Collapsing them keeps refresh and re-mint working for those rows. + if (method === "basic" || method === "basic_raw") return rawClientSecretBasic(clientSecret); return oauth.ClientSecretPost(clientSecret); };