require "base64" require "digest" require "json" require "net/http" require "securerandom" require "openssl" require "shopify_cli" require "uri" require "webrick" module ShopifyCli class OAuth include SmartProperties autoload :Servlet, "shopify-cli/oauth/servlet" class Error < StandardError; end LocalRequest = Struct.new(:method, :path, :query, :protocol) DEFAULT_PORT = 3456 REDIRECT_HOST = "http://127.0.0.1:#{DEFAULT_PORT}" property! :ctx property! :service, accepts: String property! :client_id, accepts: String property! :scopes property :store, default: -> { ShopifyCli::DB.new } property :secret, accepts: String property :request_exchange, accepts: String property :options, default: -> { {} }, accepts: Hash property :auth_path, default: "/authorize", accepts: ->(path) { path.is_a?(String) && path.start_with?("/") } property :token_path, default: "/token", accepts: ->(path) { path.is_a?(String) && path.start_with?("/") } property :state_token, accepts: String, default: SecureRandom.hex(30) property :code_verifier, accepts: String, default: SecureRandom.hex(30) attr_accessor :response_query def authenticate(url) return if refresh_exchange_token(url) return if refresh_access_token(url) initiate_authentication(url) request_access_token(url, code: receive_access_code) request_exchange_token(url) if should_exchange end def code_challenge @code_challenge ||= Base64.urlsafe_encode64( OpenSSL::Digest::SHA256.digest(code_verifier), padding: false, ) end def server @server ||= begin server = WEBrick::HTTPServer.new( Port: DEFAULT_PORT, Logger: WEBrick::Log.new(File.open(File::NULL, "w")), AccessLog: [], ) server.mount("/", Servlet, self, state_token) server end end private def initiate_authentication(url) @server_thread = Thread.new { server.start } params = { client_id: client_id, scope: scopes, redirect_uri: REDIRECT_HOST, state: state_token, response_type: :code, } params.merge!(challange_params) if secret.nil? uri = URI.parse("#{url}#{auth_path}") uri.query = URI.encode_www_form(params.merge(options)) output_authentication_info(uri) end def output_authentication_info(uri) login_location = if service == "admin" ctx.message("core.oauth.location.admin") elsif Shopifolk.acting_as_shopify_organization? ctx.message("core.oauth.location.shopifolk") else ctx.message("core.oauth.location.partner") end ctx.puts(ctx.message("core.oauth.authentication_required", login_location)) ctx.open_url!(uri) end def receive_access_code @access_code ||= begin @server_thread.join(240) raise Error, ctx.message("core.oauth.error.timeout") if response_query.nil? raise Error, response_query["error_description"] unless response_query["error"].nil? response_query["code"] end end def request_access_token(url, code:) resp = post_token_request( "#{url}#{token_path}", { grant_type: :authorization_code, code: code, redirect_uri: REDIRECT_HOST, client_id: client_id, }.merge(confirmation_param) ) store.set( "#{service}_access_token".to_sym => resp["access_token"], "#{service}_refresh_token".to_sym => resp["refresh_token"], ) end def refresh_access_token(url) return false if !store.exists?("#{service}_access_token".to_sym) || !store.exists?("#{service}_refresh_token".to_sym) refresh_token(url) request_exchange_token(url) if should_exchange true rescue store.del("#{service}_access_token".to_sym, "#{service}_refresh_token".to_sym) false end def refresh_token(url) resp = post_token_request( "#{url}#{token_path}", grant_type: :refresh_token, access_token: store.get("#{service}_access_token".to_sym), refresh_token: store.get("#{service}_refresh_token".to_sym), client_id: client_id, ) store.set( "#{service}_access_token".to_sym => resp["access_token"], "#{service}_refresh_token".to_sym => resp["refresh_token"], ) end def refresh_exchange_token(url) return false if !should_exchange || !store.exists?("#{service}_exchange_token".to_sym) request_exchange_token(url) true rescue store.del("#{service}_exchange_token".to_sym) false end def request_exchange_token(url) resp = post_token_request( "#{url}#{token_path}", grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", requested_token_type: "urn:ietf:params:oauth:token-type:access_token", subject_token_type: "urn:ietf:params:oauth:token-type:access_token", client_id: client_id, audience: request_exchange, scope: scopes, subject_token: store.get("#{service}_access_token".to_sym), ) store.set("#{service}_exchange_token".to_sym => resp["access_token"]) end def post_token_request(url, params) uri = URI.parse(url) https = Net::HTTP.new(uri.host, uri.port) https.use_ssl = true request = Net::HTTP::Post.new(uri.path) request["User-Agent"] = "Shopify App CLI #{::ShopifyCli::VERSION}" request.body = URI.encode_www_form(params) res = https.request(request) raise Error, JSON.parse(res.body)["error_description"] unless res.is_a?(Net::HTTPSuccess) JSON.parse(res.body) end def challange_params { code_challenge: code_challenge, code_challenge_method: "S256", } end def confirmation_param if secret.nil? { code_verifier: code_verifier } else { client_secret: secret } end end def should_exchange !request_exchange.nil? && !request_exchange.empty? end end end