Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/client-credentials-default-token-type.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 38 additions & 0 deletions packages/core/sdk/src/oauth-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
41 changes: 40 additions & 1 deletion packages/core/sdk/src/oauth-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,41 @@ const normalizeSlackTokenEnvelope = async (response: Response): Promise<Response
);
};

const ClientCredentialsGrant = Schema.Struct({
access_token: Schema.String,
token_type: Schema.optional(Schema.Unknown),
scope: Schema.optional(Schema.String),
});
const decodeClientCredentialsGrant = Schema.decodeUnknownOption(ClientCredentialsGrant);

/** Some providers (Shopify Admin API) answer a successful client_credentials
* grant with only `access_token`, `scope` and `expires_in`. RFC 6749 requires
* `token_type`, and oauth4webapi rejects the response without it. Default it to
* Bearer for this grant only, and read a comma-separated `scope` as a list.
* Responses that already carry a `token_type` are returned untouched. */
const normalizeClientCredentialsResponse = async (response: Response): Promise<Response> => {
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<string, unknown>),
token_type: "Bearer",
...(scope ? { scope } : {}),
}),
{
status: response.status,
statusText: response.statusText,
headers: response.headers,
},
);
};

const processTokenEndpointResponse = async (
as: oauth.AuthorizationServer,
client: oauth.Client,
Expand Down Expand Up @@ -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,
Expand Down
Loading