Skip to content

Commit dd93224

Browse files
committed
fix(oauth): use interoperable basic credentials
1 parent dd1c9f6 commit dd93224

4 files changed

Lines changed: 40 additions & 25 deletions

File tree

.changeset/oauth-client-basic-auth.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@executor-js/sdk": patch
33
---
44

5-
Allow registered OAuth clients to use `client_secret_basic` at the token endpoint. The selected client-auth method is persisted and reused for authorization-code exchanges, client-credentials mints, token refreshes, and client-credentials re-mints; existing clients continue to default to `client_secret_post`.
5+
Allow registered OAuth clients to use interoperable `client_secret_basic` at the token endpoint. Basic credentials preserve the literal client ID and secret expected by common providers. The selected client-auth method is persisted and reused for authorization-code exchanges, client-credentials mints, token refreshes, and client-credentials re-mints; existing clients continue to default to `client_secret_post`.

packages/core/sdk/src/oauth-client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ export interface OAuthClient {
7070
readonly resource?: string | null;
7171
/** How the client authenticates to the token endpoint: `"body"`
7272
* (`client_secret_post`, the default) or `"basic"` (`client_secret_basic`,
73-
* base64 `client_id:client_secret` in the Authorization header). Some
74-
* providers only accept Basic for confidential clients.
73+
* base64 of the literal UTF-8 `client_id:client_secret` pair in the
74+
* Authorization header). Some providers only accept Basic for confidential clients.
7575
* Omitted/undefined is treated as `"body"` throughout. */
7676
readonly tokenEndpointAuthMethod?: ClientAuthMethod;
7777
}

packages/core/sdk/src/oauth-helpers.test.ts

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -546,27 +546,6 @@ describe("exchangeAuthorizationCode", () => {
546546
),
547547
);
548548

549-
it.effect("uses HTTP Basic auth when clientAuth=basic (Stripe-style)", () =>
550-
withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
551-
Effect.gen(function* () {
552-
yield* exchangeAuthorizationCode({
553-
tokenUrl,
554-
clientId: "cid",
555-
clientSecret: "csecret",
556-
redirectUrl: "https://app.example.com/cb",
557-
codeVerifier: "verifier",
558-
code: "abc",
559-
clientAuth: "basic",
560-
});
561-
const call = (yield* calls)[0]!;
562-
const expected = `Basic ${Buffer.from("cid:csecret").toString("base64")}`;
563-
expect(call.headers["authorization"]).toBe(expected);
564-
expect(call.body.has("client_id")).toBe(false);
565-
expect(call.body.has("client_secret")).toBe(false);
566-
}),
567-
),
568-
);
569-
570549
it.effect("uses the documented 20-second timeout default", () =>
571550
withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl }) =>
572551
Effect.gen(function* () {
@@ -685,6 +664,26 @@ describe("exchangeAuthorizationCode", () => {
685664
});
686665

687666
describe("exchangeClientCredentials", () => {
667+
it.effect("uses literal HTTP Basic credentials when clientAuth=basic", () =>
668+
withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
669+
Effect.gen(function* () {
670+
yield* exchangeClientCredentials({
671+
tokenUrl,
672+
clientId: "client_id-with-punctuation",
673+
clientSecret: "client_secret-with-punctuation",
674+
clientAuth: "basic",
675+
});
676+
const call = (yield* calls)[0]!;
677+
const expected = `Basic ${Buffer.from(
678+
"client_id-with-punctuation:client_secret-with-punctuation",
679+
).toString("base64")}`;
680+
expect(call.headers["authorization"]).toBe(expected);
681+
expect(call.body.has("client_id")).toBe(false);
682+
expect(call.body.has("client_secret")).toBe(false);
683+
}),
684+
),
685+
);
686+
688687
it.effect("routes token grant requests through the injected fetch", () =>
689688
withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl }) =>
690689
Effect.gen(function* () {

packages/core/sdk/src/oauth-helpers.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,10 +556,26 @@ const pickClientAuth = (
556556
): oauth.ClientAuth => {
557557
if (!clientSecret) return oauth.None();
558558
return method === "basic"
559-
? oauth.ClientSecretBasic(clientSecret)
559+
? clientSecretBasicInterop(clientSecret)
560560
: oauth.ClientSecretPost(clientSecret);
561561
};
562562

563+
// oauth4webapi's ClientSecretBasic follows RFC 6749 strictly by form-encoding
564+
// both credentials before base64 encoding them. A number of token endpoints
565+
// instead implement HTTP Basic per RFC 7617 and compare the decoded username
566+
// and password literally. Characters such as `_` therefore become `%5F` and
567+
// otherwise-valid credentials are rejected. Use the broadly interoperable
568+
// wire representation expected by those endpoints: base64 of the literal
569+
// UTF-8 `client_id:client_secret` pair.
570+
const clientSecretBasicInterop =
571+
(clientSecret: string): oauth.ClientAuth =>
572+
(_as, client, _body, headers) => {
573+
const bytes = new TextEncoder().encode(`${client.client_id}:${clientSecret}`);
574+
let binary = "";
575+
for (const byte of bytes) binary += String.fromCharCode(byte);
576+
headers.set("authorization", `Basic ${globalThis.btoa(binary)}`);
577+
};
578+
563579
const tokenResponseFrom = (r: oauth.TokenEndpointResponse): OAuth2TokenResponse => ({
564580
access_token: r.access_token,
565581
token_type: r.token_type,

0 commit comments

Comments
 (0)