diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f7995a..0d9b6602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ Note: For changes to the API, see https://shopify.dev/changelog?filter=api ## Unreleased -[#1210](https://github.com/Shopify/shopify-api-ruby/pull/1246) Add context option `response_as_struct` to allow GraphQL API responses to be accessed via dot notation. +- [#1210](https://github.com/Shopify/shopify-api-ruby/pull/1246) Add context option `response_as_struct` to allow GraphQL API responses to be accessed via dot notation. +- [#1254](https://github.com/Shopify/shopify-api-ruby/pull/1254) Introduce token exchange API for fetching access tokens. This feature is currently unstable and cannot be used yet. ## 13.3.1 diff --git a/lib/shopify_api/auth/oauth.rb b/lib/shopify_api/auth/oauth.rb index 8046d7b3..61093ecb 100644 --- a/lib/shopify_api/auth/oauth.rb +++ b/lib/shopify_api/auth/oauth.rb @@ -8,6 +8,16 @@ module Oauth NONCE_LENGTH = 15 + TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" + ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token" + + class RequestedTokenType < T::Enum + enums do + ONLINE_ACCESS_TOKEN = new("urn:shopify:params:oauth:token-type:online-access-token") + OFFLINE_ACCESS_TOKEN = new("urn:shopify:params:oauth:token-type:offline-access-token") + end + end + class << self extend T::Sig @@ -70,19 +80,10 @@ def validate_auth_callback(cookies:, auth_query:) raise Errors::InvalidOauthError, "Invalid state in OAuth callback." unless state == auth_query.state - null_session = Auth::Session.new(shop: auth_query.shop) body = { client_id: Context.api_key, client_secret: Context.api_secret_key, code: auth_query.code } - client = Clients::HttpClient.new(session: null_session, base_path: "/admin/oauth") response = begin - client.request( - Clients::HttpRequest.new( - http_method: :post, - path: "access_token", - body: body, - body_type: "application/json", - ), - ) + access_token_request(auth_query.shop, body) rescue ShopifyAPI::Errors::HttpResponseError => e raise Errors::RequestAccessTokenError, "Cannot complete OAuth process. Received a #{e.code} error while requesting access token." @@ -106,8 +107,71 @@ def validate_auth_callback(cookies:, auth_query:) { session: session, cookie: cookie } end + sig do + params( + shop: String, + session_token: String, + requested_token_type: RequestedTokenType, + ).returns(ShopifyAPI::Auth::Session) + end + def token_exchange(shop:, session_token:, requested_token_type:) + unless ShopifyAPI::Context.setup? + raise ShopifyAPI::Errors::ContextNotSetupError, + "ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup" + end + raise ShopifyAPI::Errors::UnsupportedOauthError, + "Cannot perform OAuth Token Exchange for private apps." if ShopifyAPI::Context.private? + raise ShopifyAPI::Errors::UnsupportedOauthError, + "Cannot perform OAuth Token Exchange for non embedded apps." unless ShopifyAPI::Context.embedded? + + # Validate the session token content + ShopifyAPI::Auth::JwtPayload.new(session_token) + + body = { + client_id: ShopifyAPI::Context.api_key, + client_secret: ShopifyAPI::Context.api_secret_key, + grant_type: TOKEN_EXCHANGE_GRANT_TYPE, + subject_token: session_token, + subject_token_type: ID_TOKEN_TYPE, + requested_token_type: requested_token_type.serialize, + } + + response = begin + access_token_request(shop, body) + rescue ShopifyAPI::Errors::HttpResponseError => error + if error.code == 400 && error.response.body["error"] == "invalid_subject_token" + raise ShopifyAPI::Errors::InvalidJwtTokenError, "Session token was rejected by token exchange" + end + + raise error + end + + session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h + session = create_new_session(session_params, shop) + + session + end + private + sig do + params(shop: String, + body: T.nilable(T.any(T::Hash[T.any(Symbol, String), T.untyped], + String))).returns(ShopifyAPI::Clients::HttpResponse) + end + def access_token_request(shop, body) + null_session = ShopifyAPI::Auth::Session.new(shop: shop) + client = ShopifyAPI::Clients::HttpClient.new(session: null_session, base_path: "/admin/oauth") + client.request( + ShopifyAPI::Clients::HttpRequest.new( + http_method: :post, + path: "access_token", + body: body, + body_type: "application/json", + ), + ) + end + sig { params(session_params: T::Hash[String, T.untyped], shop: String).returns(Session) } def create_new_session(session_params, shop) session_params = session_params.to_h { |k, v| [k.to_sym, v] } diff --git a/test/auth/oauth_test.rb b/test/auth/oauth_test.rb index d8ed15c4..d7eccd0a 100644 --- a/test/auth/oauth_test.rb +++ b/test/auth/oauth_test.rb @@ -41,7 +41,26 @@ def setup client_secret: ShopifyAPI::Context.api_secret_key, code: @callback_code, } - + @jwt_payload = { + iss: "https://#{@shop}/admin", + dest: "https://#{@shop}", + aud: ShopifyAPI::Context.api_key, + sub: "1", + exp: (Time.now + 10).to_i, + nbf: 1234, + iat: 1234, + jti: "4321", + sid: "abc123", + } + @session_token = JWT.encode(@jwt_payload, ShopifyAPI::Context.api_secret_key, "HS256") + @token_exchange_request = { + client_id: ShopifyAPI::Context.api_key, + client_secret: ShopifyAPI::Context.api_secret_key, + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token_type: "urn:ietf:params:oauth:token-type:id_token", + subject_token: @session_token, + requested_token_type: "urn:shopify:params:oauth:token-type:offline-access-token", + } @offline_token_response = { access_token: SecureRandom.alphanumeric(10), scope: "scope1,scope2", @@ -263,6 +282,124 @@ def test_validate_auth_callback_bad_http_response end end + def test_token_exchange_context_not_setup + modify_context(api_key: "", api_secret_key: "", host: "") + + assert_raises(ShopifyAPI::Errors::ContextNotSetupError) do + ShopifyAPI::Auth::Oauth.token_exchange( + shop: @shop, + session_token: @session_token, + requested_token_type: ShopifyAPI::Auth::Oauth::RequestedTokenType::OFFLINE_ACCESS_TOKEN, + ) + end + end + + def test_token_exchange_private_app + modify_context(is_private: true) + + assert_raises(ShopifyAPI::Errors::UnsupportedOauthError) do + ShopifyAPI::Auth::Oauth.token_exchange( + shop: @shop, + session_token: @session_token, + requested_token_type: ShopifyAPI::Auth::Oauth::RequestedTokenType::OFFLINE_ACCESS_TOKEN, + ) + end + end + + def test_token_exchange_not_embedded_app + modify_context(is_embedded: false) + + assert_raises(ShopifyAPI::Errors::UnsupportedOauthError) do + ShopifyAPI::Auth::Oauth.token_exchange( + shop: @shop, + session_token: @session_token, + requested_token_type: ShopifyAPI::Auth::Oauth::RequestedTokenType::OFFLINE_ACCESS_TOKEN, + ) + end + end + + def test_token_exchange_invalid_session_token + modify_context(is_embedded: true) + + assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do + ShopifyAPI::Auth::Oauth.token_exchange( + shop: @shop, + session_token: "invalid", + requested_token_type: ShopifyAPI::Auth::Oauth::RequestedTokenType::OFFLINE_ACCESS_TOKEN, + ) + end + end + + def test_token_exchange_rejected_session_token + modify_context(is_embedded: true) + stub_request(:post, "https://#{@shop}/admin/oauth/access_token") + .with(body: @token_exchange_request) + .to_return( + status: 400, + body: { error: "invalid_subject_token" }.to_json, + headers: { content_type: "application/json" }, + ) + + assert_raises(ShopifyAPI::Errors::InvalidJwtTokenError) do + ShopifyAPI::Auth::Oauth.token_exchange( + shop: @shop, + session_token: @session_token, + requested_token_type: ShopifyAPI::Auth::Oauth::RequestedTokenType::OFFLINE_ACCESS_TOKEN, + ) + end + end + + def test_token_exchange_offline_token + modify_context(is_embedded: true) + stub_request(:post, "https://#{@shop}/admin/oauth/access_token") + .with(body: @token_exchange_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: nil, + ) + + session = ShopifyAPI::Auth::Oauth.token_exchange( + shop: @shop, + session_token: @session_token, + requested_token_type: ShopifyAPI::Auth::Oauth::RequestedTokenType::OFFLINE_ACCESS_TOKEN, + ) + + assert_equal(expected_session, session) + end + + def test_token_exchange_online_token + modify_context(is_embedded: true) + stub_request(:post, "https://#{@shop}/admin/oauth/access_token") + .with(body: @token_exchange_request.dup.tap do |h| + h[:requested_token_type] = "urn:shopify:params:oauth:token-type:online-access-token" + end) + .to_return(body: @online_token_response.to_json, headers: { content_type: "application/json" }) + expected_session = ShopifyAPI::Auth::Session.new( + id: "#{@shop}_#{@online_token_response[:associated_user][:id]}", + shop: @shop, + access_token: @online_token_response[:access_token], + scope: @online_token_response[:scope], + associated_user_scope: @online_token_response[:associated_user_scope], + expires: @stubbed_time_now + @online_token_response[:expires_in].to_i, + associated_user: @expected_associated_user, + ) + + session = Time.stub(:now, @stubbed_time_now) do + ShopifyAPI::Auth::Oauth.token_exchange( + shop: @shop, + session_token: @session_token, + requested_token_type: ShopifyAPI::Auth::Oauth::RequestedTokenType::ONLINE_ACCESS_TOKEN, + ) + end + + assert_equal(expected_session, session) + end + private def verify_oauth_begin(auth_route:, cookie:, is_online:, scope: ShopifyAPI::Context.scope)