Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature]: Add support for client credentials grant #1362

Merged
merged 1 commit into from
Feb 21, 2025
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Note: For changes to the API, see https://shopify.dev/changelog?filter=api
## Unreleased

- [#1362](https://github.com/Shopify/shopify-api-ruby/pull/1362) Add support for client credentials grant

## 14.8.0

- [#1355](https://github.com/Shopify/shopify-api-ruby/pull/1355) Add support for 2025-01 API version
Expand Down
45 changes: 41 additions & 4 deletions docs/usage/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ For more information on authenticating a Shopify app please see the [Types of Au
- [Note about Rails](#note-about-rails)
- [Performing OAuth](#performing-oauth-1)
- [Token Exchange](#token-exchange)
- [Authorization Code Grant Flow](#authorization-code-grant-flow)
- [Authorization Code Grant](#authorization-code-grant)
- [Client Credentials Grant](#client-credentials-grant)
- [Using OAuth Session to make authenticated API calls](#using-oauth-session-to-make-authenticated-api-calls)

## Session Persistence
Expand All @@ -31,10 +32,14 @@ with [token exchange](#token-exchange) instead of the authorization code grant f
- Recommended and is only available for embedded apps
- Doesn't require redirects, which makes authorization faster and prevents flickering when loading the app
- Access scope changes are handled by [Shopify managed installation](https://shopify.dev/docs/apps/auth/installation#shopify-managed-installation)
2. [Authorization Code Grant Flow](#authorization-code-grant-flow)
2. [Authorization Code Grant](#authorization-code-grant)
- OAuth flow that requires the app to redirect the user to Shopify for installation/authorization of the app to access the shop's data.
- Suitable for non-embedded apps
- Installations, and access scope changes are managed by the app
3. [Client Credentials Grant](#client-credentials-grant)
- Suitable for apps without a UI
- Doesn't require user interaction in the browser
- Access scope changes are handled by [Shopify managed installation](https://shopify.dev/docs/apps/auth/installation#shopify-managed-installation)

## Note about Rails
If using in the Rails framework, we highly recommend you use the [shopify_app](https://github.com/Shopify/shopify_app) gem to perform OAuth, you won't have to follow the instructions below to start your own OAuth flow.
Expand Down Expand Up @@ -94,7 +99,7 @@ end

```

### Authorization Code Grant Flow
### Authorization Code Grant
##### Steps
1. [Add a route to start OAuth](#1-add-a-route-to-start-oauth)
2. [Add an Oauth callback route](#2-add-an-oauth-callback-route)
Expand Down Expand Up @@ -265,9 +270,41 @@ def callback
end
end
```

⚠️ You can see a concrete example in the `ShopifyApp` gem's [CallbackController](https://github.com/Shopify/shopify_app/blob/main/app/controllers/shopify_app/callback_controller.rb).

### Client Credentials Grant

> [!NOTE]
> You can only use the client credentials grant when building apps for your own organization.

> [!WARNING]
> [token exchange](#token-exchange) (for embedded apps) or the [authorization code grant](#authorization-code-grant) should be used instead of the client credentials grant, if your app is a browser based web app.

#### Perform Client Credentials Grant
Use [`ShopifyAPI::Auth::ClientCredentials`](https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/auth/client_credentials.rb) to
exchange the [app's client ID and client secret](https://shopify.dev/docs/apps/build/authentication-authorization/client-secrets) for an access token.
#### Input
| Parameter | Type | Required? | Default Value | Notes |
| -------------- | ---------------------- | :-------: | :-----------: | ----------------------------------------------------------------------------------------------------------- |
| `shop` | `String` | Yes | - | A Shopify domain name in the form `{exampleshop}.myshopify.com`. |

#### Output
This method returns the new `ShopifyAPI::Auth::Session` object from the client credentials grant, your app should store this `Session` object to be used later [when making authenticated API calls](#using-oauth-session-to-make-authenticated-api-calls).

#### Example
```ruby

# `shop` is the shop domain name - "this-is-my-example-shop.myshopify.com"

def authenticate(shop)
session = ShopifyAPI::Auth::ClientCredentials.client_credentials(
shop: shop,
)
SessionRepository.store_session(session)
end

```

## Using OAuth Session to make authenticated API calls
Once your OAuth flow is complete, and you have persisted your `Session` object, you may use that `Session` object to make authenticated API calls.

Expand Down
52 changes: 52 additions & 0 deletions lib/shopify_api/auth/client_credentials.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# typed: strict
# frozen_string_literal: true

module ShopifyAPI
module Auth
module ClientCredentials
extend T::Sig

CLIENT_CREDENTIALS_GRANT_TYPE = "client_credentials"

class << self
extend T::Sig

sig do
params(
shop: String,
).returns(ShopifyAPI::Auth::Session)
end
def client_credentials(shop:)
unless ShopifyAPI::Context.setup?
raise ShopifyAPI::Errors::ContextNotSetupError,
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
end

shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
body = {
client_id: ShopifyAPI::Context.api_key,
client_secret: ShopifyAPI::Context.api_secret_key,
grant_type: CLIENT_CREDENTIALS_GRANT_TYPE,
}

client = Clients::HttpClient.new(session: shop_session, base_path: "/admin/oauth")
response =
client.request(
Clients::HttpRequest.new(
http_method: :post,
path: "access_token",
body: body,
body_type: "application/json",
),
)
response_hash = T.cast(response.body, T::Hash[String, T.untyped]).to_h

Session.from(
shop: shop,
access_token_response: Oauth::AccessTokenResponse.from_hash(response_hash),
)
end
end
end
end
end
5 changes: 4 additions & 1 deletion lib/shopify_api/auth/session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,16 @@ def from(shop:, access_token_response:)

if is_online
associated_user = T.must(access_token_response.associated_user)
expires = Time.now + access_token_response.expires_in.to_i
associated_user_scope = access_token_response.associated_user_scope
id = "#{shop}_#{associated_user.id}"
else
id = "offline_#{shop}"
end

if access_token_response.expires_in
expires = Time.now + access_token_response.expires_in.to_i
end

new(
id: id,
shop: shop,
Expand Down
52 changes: 52 additions & 0 deletions test/auth/client_credentials_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# typed: false
# frozen_string_literal: true

require_relative "../test_helper"

module ShopifyAPITest
module Auth
class ClientCredentialsTest < Test::Unit::TestCase
def setup
super()

@shop = "test-shop.myshopify.com"
@client_credentials_request = {
client_id: ShopifyAPI::Context.api_key,
client_secret: ShopifyAPI::Context.api_secret_key,
grant_type: "client_credentials",
}
@offline_token_response = {
access_token: SecureRandom.alphanumeric(10),
scope: "scope1,scope2",
expires_in: 1000,
}
end

def test_client_credentials_context_not_setup
modify_context(api_key: "", api_secret_key: "", host: "")

assert_raises(ShopifyAPI::Errors::ContextNotSetupError) do
ShopifyAPI::Auth::ClientCredentials.client_credentials(shop: @shop)
end
end

def test_client_credentials_offline_token
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
.with(body: @client_credentials_request)
.to_return(body: @offline_token_response.to_json, headers: { content_type: "application/json" })
expected_session = ShopifyAPI::Auth::Session.new(
id: "offline_#{@shop}",
shop: @shop,
access_token: @offline_token_response[:access_token],
scope: @offline_token_response[:scope],
is_online: false,
expires: Time.now + @offline_token_response[:expires_in].to_i,
)

session = ShopifyAPI::Auth::ClientCredentials.client_credentials(shop: @shop)

assert_equal(expected_session, session)
end
end
end
end
26 changes: 25 additions & 1 deletion test/auth/session_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def test_temp_with_block_var
assert_equal(session, ShopifyAPI::Context.active_session)
end

def test_from_with_offline_access_token_response
def test_from_with_offline_access_token_response_with_no_expires_in
shop = "test-shop"
response = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new(
access_token: "token",
Expand All @@ -103,6 +103,30 @@ def test_from_with_offline_access_token_response
assert_equal(expected_session, session)
end

def test_from_with_offline_access_token_response_with_expires_in
shop = "test-shop"
response = ShopifyAPI::Auth::Oauth::AccessTokenResponse.new(
access_token: "token",
scope: "scope1, scope2",
expires_in: 1000,
)

expected_session = ShopifyAPI::Auth::Session.new(
id: "offline_#{shop}",
shop: shop,
access_token: response.access_token,
scope: response.scope,
is_online: false,
associated_user_scope: nil,
associated_user: nil,
expires: Time.now + response.expires_in,
shopify_session_id: response.session,
)

session = ShopifyAPI::Auth::Session.from(shop: shop, access_token_response: response)
assert_equal(expected_session, session)
end

def test_from_with_online_access_token_response
shop = "test-shop"
associated_user = ShopifyAPI::Auth::AssociatedUser.new(
Expand Down