Skip to content

[bug] client_credentials sign-in fails when the token response has no token_type (Shopify Admin API) #2090

Description

@ianthompson

Executor version

1.6.10

How do you run Executor?

Self-host (Docker)

Operating system

Linux

Integration involved

GraphQL API (Shopify Admin API), OAuth client with grant client_credentials

What happened

I registered an OAuth client with grant: "client_credentials" and tokenEndpointAuthMethod: "body" for a Shopify Dev Dashboard app, then called POST /api/oauth/start to create the connection.

  • Token URL: https://{shop}.myshopify.com/admin/oauth/access_token
  • Shopify accepts the request and returns HTTP 200 with a valid token.
  • Executor rejects the response and returns HTTP 400:
OAuth client-credentials exchange failed: OAuth token exchange failed:
"response" body "token_type" property must be a string
(HTTP 200; body: {"access_token":"[redacted]","scope":"[redacted]","expires_in":86399})

Shopify's client credentials response has three fields: access_token, scope and expires_in. It never sends token_type. Shopify documents this shape here: https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/client-credentials-grant

The rejection comes from oauth.processClientCredentialsResponse in packages/core/sdk/src/oauth-helpers.ts (the clientCredentialsGrant path, around line 1396 on main). oauth4webapi requires token_type to be a string. The response goes to it unchanged.

What you expected

The connection is created and Executor renews the 24-hour token on its own.

Shopify stopped allowing new admin-created custom apps on 1 January 2026, so client credentials is now the only way for a server-side tool to get an Admin API token for a store in the same organisation. Without this, Shopify Admin cannot sign in through Executor at all. The only workaround is an outside job that fetches a token and replaces the connection every day.

Suggested fix

The authorization-code path already normalises a provider's odd response before oauth4webapi sees it (normalizeSlackTokenEnvelope). The same idea works here: when a 2xx token response has an access_token and no token_type, fill in token_type: "Bearer" before calling processClientCredentialsResponse. The refresh path would need the same treatment.

Two related details in Shopify's response, in case they matter to the same fix:

  • scope is comma-separated, not space-separated. normalizedTokenScope may already cover this.
  • There is no refresh_token. Renewal means repeating the client credentials request, which I believe Executor already does for this grant.

Steps to reproduce

  1. Create an app in the Shopify Dev Dashboard, give it any Admin API scope and install it on a store in the same organisation.
  2. POST /api/oauth/clients with grant: "client_credentials", tokenEndpointAuthMethod: "body", the app's client ID and secret, and the token URL above.
  3. Add a GraphQL integration for https://{shop}.myshopify.com/admin/api/2026-07/graphql.json with an oauth2 template that uses header X-Shopify-Access-Token and an empty prefix.
  4. POST /api/oauth/start with that client, integration and template.
  5. The call returns HTTP 400 with the message above.

I checked main at 1.6.10 and the open and closed issues. #1447 and #1217 are near this area but neither covers a missing token_type.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions