From a51dfaaef5ca67e73531199f931c5f6ace20e9d2 Mon Sep 17 00:00:00 2001 From: Fwaad Ahmad Date: Thu, 20 Feb 2025 11:24:49 -0500 Subject: [PATCH] Add support for client credentials in Shopify API Ruby --- CHANGELOG.md | 2 + docs/usage/oauth.md | 39 +++++++++++++++- lib/shopify_api/auth/client_credentials.rb | 52 +++++++++++++++++++++ lib/shopify_api/auth/session.rb | 5 +- test/auth/client_credentials_test.rb | 53 ++++++++++++++++++++++ test/auth/session_test.rb | 26 ++++++++++- 6 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 lib/shopify_api/auth/client_credentials.rb create mode 100644 test/auth/client_credentials_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index a77ae1f25..e58fad684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/usage/oauth.md b/docs/usage/oauth.md index 12527090d..02a5c8731 100644 --- a/docs/usage/oauth.md +++ b/docs/usage/oauth.md @@ -12,6 +12,7 @@ For more information on authenticating a Shopify app please see the [Types of Au - [Performing OAuth](#performing-oauth-1) - [Token Exchange](#token-exchange) - [Authorization Code Grant Flow](#authorization-code-grant-flow) + - [Client Credentials Grant](#client-credentials-grant) - [Using OAuth Session to make authenticated API calls](#using-oauth-session-to-make-authenticated-api-calls) ## Session Persistence @@ -35,6 +36,10 @@ with [token exchange](#token-exchange) instead of the authorization code grant f - 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 backend apps without UI + - Doesn't require user interaction in the browser + - Access scopes can be configured either in the Developer Dashboard when creating an app version or in your app's [TOML configuration file](https://shopify.dev/docs/apps/build/cli-for-apps/app-configuration#access_scopes) ## 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. @@ -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 flow](#authorization-code-grant-flow) 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. diff --git a/lib/shopify_api/auth/client_credentials.rb b/lib/shopify_api/auth/client_credentials.rb new file mode 100644 index 000000000..5775c9686 --- /dev/null +++ b/lib/shopify_api/auth/client_credentials.rb @@ -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 diff --git a/lib/shopify_api/auth/session.rb b/lib/shopify_api/auth/session.rb index 1fba413b0..68cef2774 100644 --- a/lib/shopify_api/auth/session.rb +++ b/lib/shopify_api/auth/session.rb @@ -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, diff --git a/test/auth/client_credentials_test.rb b/test/auth/client_credentials_test.rb new file mode 100644 index 000000000..d66adeb44 --- /dev/null +++ b/test/auth/client_credentials_test.rb @@ -0,0 +1,53 @@ +# typed: false +# frozen_string_literal: true + +require_relative "../test_helper" + +module ShopifyAPITest + module Auth + class ClientCredentialsTest < Test::Unit::TestCase + def setup + super() + + @stubbed_time_now = Time.now + @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: @stubbed_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 diff --git a/test/auth/session_test.rb b/test/auth/session_test.rb index 4f55d3923..065b43433 100644 --- a/test/auth/session_test.rb +++ b/test/auth/session_test.rb @@ -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", @@ -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(