Skip to content

Add JWT client assertion support to client credentials - #1475

Draft
zzooeeyy wants to merge 3 commits into
mainfrom
zl/client-assertions
Draft

zzooeeyy wants to merge 3 commits into
mainfrom
zl/client-assertions

Conversation

@zzooeeyy

Copy link
Copy Markdown
Contributor

Description

Adds JWT client assertion authentication (RFC 7523) to the client credentials grant, so an app can authenticate a token request with a key it owns instead of the client secret.

  • ShopifyAPI::Auth::ClientCredentials.client_credentials takes an optional client_assertion:. When passed, the request body carries only client_id, grant_type, client_assertion_type and client_assertion, with no client secret. Omitting it, or passing nil, keeps today's client-secret behavior.
  • Add ShopifyAPI::Auth::ClientAssertion.create, an optional helper that signs one assertion with an RS256 (RSA, 2048-bit minimum) or ES256 (P-256) key the app has already loaded. It sets kid and typ: JWT, sets iss and sub to the client ID, sets aud to the shop's token endpoint, and adds a random jti. The default lifetime is 60 seconds; an integer from 1 to 300 is accepted.
  • Add ShopifyAPI::Errors::InvalidClientAssertionError for a blank or non-String assertion and for invalid signer input.
  • Before sending an assertion request, reject other credentials: an Authorization or X-Shopify-Access-Token header, URL userinfo, or HTTParty default query params. Turn off inherited Basic/digest auth for that request only, with the new HttpClient#request(..., http_authentication: false).
  • Compatibility change: client-credentials requests no longer follow redirects, for both client-secret and assertion authentication. A 3xx from the token endpoint now raises instead of being followed. Other requests and global HTTParty defaults are unchanged.

Why

  • Redirects: a 307 or 308 resends the POST body, so following one can forward the client secret or the assertion to whatever host the response names. The token exchange doesn't need redirects.
  • One attempt: an assertion authenticates once, so assertion requests don't retry. A second attempt needs a newly signed assertion, which only the app can make.
  • Optional signer: ClientAssertion.create is stateless and doesn't read ShopifyAPI::Context. It never imports, generates, stores or rotates keys. Apps that sign elsewhere, for example in a KMS, pass the JWT directly.
  • Per-request auth override: a flag on HttpClient#request instead of a separate transport keeps the existing proxy, CA and timeout settings.

How has this been tested?

  • bundle exec rake test: 401 runs, 1,068 assertions, 0 failures.
  • bundle exec srb tc: no errors. bundle exec rubocop: no offenses.
  • Tophat with a small app against a local Shopify development environment and a production test store, with real HTTP and TLS verification on:
Case Result
Production store: ClientAssertion.create (ES256), then client_credentials(client_assertion:), then an Admin GraphQL shop query ✅ Token returned, query 200
Local: exchange an app-signed ES256 assertion, then an Admin GraphQL shop query ✅ Token returned, query 200
Local: replay the same assertion before it expires ✅ 401 invalid_client, no token
Local: a real 307 from the token endpoint, client secret and assertion ✅ Not followed for either, no token

client_credentials returns a Session, so the token response's status isn't visible in these runs. The local runs used an app-side signer because ClientAssertion.create only builds .myshopify.com audiences.

Checklist:

  • My commit message follow the pattern described in here
  • I have performed a self-review of my own code.
  • I have added tests that prove my fix is effective or that my feature works.
  • I have updated the project documentation.
  • I have added a changelog line.

Generated with Pi.

`ShopifyAPI::Auth::ClientCredentials.client_credentials` takes an optional
`client_assertion:`. When passed, the token request authenticates with the
JWT assertion, sends no client secret, and disables inherited HTTP Basic or
digest auth for that request only. `ShopifyAPI::Auth::ClientAssertion.create`
is an optional stateless signer for `RS256` and `ES256` keys.

Client-credentials requests no longer follow redirects for either
authentication mode, since the request body carries the app's credentials.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the devtools-gardener Post the issue or PR to Slack for the gardener label Oct 27, 2026

This branch has not been deployed

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

Labels

devtools-gardener Post the issue or PR to Slack for the gardener

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant