diff --git a/.changeset/client-credentials-default-token-type.md b/.changeset/client-credentials-default-token-type.md new file mode 100644 index 0000000000..951b16a89b --- /dev/null +++ b/.changeset/client-credentials-default-token-type.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +A client_credentials sign-in no longer fails when the token response leaves out `token_type`, as Shopify's Admin API does. The grant now defaults to Bearer and reads a comma-separated `scope` as a list. diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index 92bc623d06..a0cfe10350 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -1379,6 +1379,44 @@ describe("exchangeClientCredentials", () => { ), ); + it.effect("defaults token_type to Bearer when a client_credentials response omits it", () => + withTokenEndpoint( + // Shopify Admin API shape: no token_type, comma-separated scope. + tokenResponse({ + access_token: "shpat_tok", + scope: "read_products,write_products", + expires_in: 86399, + }), + ({ tokenUrl }) => + Effect.gen(function* () { + const token = yield* exchangeClientCredentials({ + tokenUrl, + clientId: "cid", + clientSecret: "secret", + }); + expect(token.access_token).toBe("shpat_tok"); + expect(token.token_type).toBe("bearer"); + expect(token.scope).toBe("read_products write_products"); + expect(token.expires_in).toBe(86399); + }), + ), + ); + + it.effect("keeps an explicit token_type on a client_credentials response", () => + withTokenEndpoint( + tokenResponse({ access_token: "tok", token_type: "DPoP", expires_in: 60 }), + ({ tokenUrl }) => + Effect.gen(function* () { + const token = yield* exchangeClientCredentials({ + tokenUrl, + clientId: "cid", + clientSecret: "secret", + }); + expect(token.token_type).toBe("dpop"); + }), + ), + ); + it.effect("rejects unsupported token URL schemes before exchange", () => Effect.gen(function* () { const exit = yield* Effect.exit( diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index b8b11714c2..f289108a88 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -1174,6 +1174,41 @@ const normalizeSlackTokenEnvelope = async (response: Response): Promise => { + if (!response.ok) return response; + const body = await safeJsonFromResponse(response); + const decoded = decodeClientCredentialsGrant(body); + if (Option.isNone(decoded) || decoded.value.token_type !== undefined) return response; + const scope = decoded.value.scope + ?.split(/[\s,]+/) + .filter(Boolean) + .join(" "); + return new Response( + JSON.stringify({ + ...(body as Record), + token_type: "Bearer", + ...(scope ? { scope } : {}), + }), + { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }, + ); +}; + const processTokenEndpointResponse = async ( as: oauth.AuthorizationServer, client: oauth.Client, @@ -1393,7 +1428,11 @@ export const exchangeClientCredentials = ( input.fetch, ), ); - const result = await oauth.processClientCredentialsResponse(as, client, response); + const result = await oauth.processClientCredentialsResponse( + as, + client, + await normalizeClientCredentialsResponse(response), + ); return tokenResponseFrom(as, result); }, catch: (cause) => cause,