Skip to main content

GitHub アプリを使用して CLI を構築する

このチュートリアルでは、デバイス フローを介して GitHub App 用のユーザー アクセス トークンを生成する CLI を Ruby で記述する手順を説明します。

はじめに

このチュートリアルでは、GitHub App を利用してコマンド ライン インターフェイス (CLI) を構築する方法と、デバイス フローを使ってアプリ用のユーザー アクセス トークンを生成する方法について説明します。

CLI には、次の 3 つのコマンドがあります。

  • help: 使用手順を表示します。
  • login: ユーザーに代わってアプリが API 要求を行うために使用できるユーザー アクセス トークンを生成します。
  • whoami: ログインしているユーザーに関する情報を返します。

このチュートリアルでは Ruby を使いますが、任意のプログラミング言語で CLI を記述し、デバイス フローを使ってユーザー アクセス トークンを生成��きます。

デバイス フローとユーザー アクセス トークンについて

この CLI では、デバイス フローを使ってユーザーを認証し、ユーザー アクセス トークンを生成します。 その後、CLI は、そのユーザー アクセス トークンを使って、認証されたユーザーの代わりに API 要求を行うことができます。

アプリのアクションをユーザーの属性にする場合は、アプリでユーザー アクセス トークンを使う必要があります。 詳しくは、「ユーザーに代わって GitHub アプリで認証する」をご覧ください。

GitHub App 用のユーザー アクセス トークンを生成するには、Web アプリケーション フローとデバイス フローの 2 つの方法があります。 アプリがヘッドレスの場合、または Web インターフェイスにアクセスできない場合は、デバイス フローを使ってユーザー アクセス トークンを生成する必要があります。 たとえば、CLI ツール、シンプルな Raspberry Pis、デスクトップ アプリケーションでは、デバイス フローを使う必要があります。 アプリが Web インターフェイスにアクセスできる場合は、代わりに Web アプリケーション フローを使う必要があります。 詳細については、「GitHub アプリのユーザー アクセス トークンの生成」および「GitHub App を使って [Login with GitHub] ボタンを作成する」を参照してください。

前提条件

このチュートリアルでは、GitHub App を既に登録済みであることを前提としています。 GitHub App の登録の詳細については、「GitHub App の登録」を参照してください。

このチュートリアルを始める前に、アプリでデバイス フローを有効にする必要があります。 アプリでデバイス フローを有効にする方法の詳細については、「GitHub App 登録の変更」を参照してください。

このチュートリアルは、読者が Ruby の基礎を理解しているものとして書かれています。 詳しくは、Ruby の Web サイトをご覧ください。

クライアント ID を取得する

デバイス フローを使ってユーザー アクセス トークンを生成するには、アプリのクライアント ID が必要です。

  1. GitHub の任意のページの右上隅にある、自分のプロファイル写真をクリックします。
  2. アカウント設定にアクセスしてください。
    • 個人用アカウントが所有するアプリの場合は、[設定] をクリックします。
    • 組織が所有するアプリの場合:
      1. [自分の組織] をクリックします。
      2. 組織の右側にある [設定] をクリックします。
  3. 左側のサイドバーで [ 開発者設定] をクリックします。
  4. 左側のサイドバーで、 [GitHub Apps] をクリックします。
  5. 作業したい GitHub App の横にある [編集] を選びます。
  6. アプリの設定ページで、ご自分のアプリのクライアント ID を見つけます。 このチュートリアルで後ほどそれを使います。 クライアント ID は、アプリ ID とは異なることに注意してください。

CLI を記述する

以下の手順では、CLI を構築し、デバイス フローを使ってユーザー アクセス トークンを取得します。 スキップして最終的なコードに進むには、「完全なコードの例」を参照してください。

セットアップ

  1. ユーザー アクセス トークンを生成するコードを保持する Ruby ファイルを作成します。 このチュートリアルでは、ファイルに app_cli.rb という名前を付けます。

  2. ターミナルで、app_cli.rb が格納されているディレクトリから次のコマンドを実行して、app_cli.rb 実行可能ファイルを作成します。

    Text
    chmod +x app_cli.rb
    
  3. 次の行を app_cli.rb の先頭に追加して、Ruby インタープリターを使ってスクリプトを実行する必要があることを示します。

    Ruby
    #!/usr/bin/env ruby
    
  4. app_cli.rb の先頭の #!/usr/bin/env ruby の後に、これらの依存関係を追加します。

    Ruby
    require "net/http"
    require "json"
    require "uri"
    require "fileutils"
    

    これらはすべて Ruby 標準ライブラリの一部であるため、gem をインストールする必要はありません。

  5. エントリ ポイントとして機能する次の main 関数を追加します。 この関数には、指定されたコマンドに応じて異なるアクションを実行する case ステートメントが含まれます。 この case ステートメントを後で拡張します。

    Ruby
    def main
      case ARGV[0]
      when "help"
        puts "`help` is not yet defined"
      when "login"
        puts "`login` is not yet defined"
      when "whoami"
        puts "`whoami` is not yet defined"
      else
        puts "Unknown command `#{ARGV[0]}`"
      end
    end
    
  6. ファイルの末尾に、エントリ ポイント関数を呼び出す次の行を追加します。 チュートリアルで後ほどこのファイルにさらに関数を追加するときも、この関数呼び出しをファイルの末尾にしておく必要があります。

    Ruby
    main
    
  7. 必要に応じて、進行状況をチェックします。

    ここまでで、app_cli.rb は次のようになっています。

    Ruby
    #!/usr/bin/env ruby
    
    require "net/http"
    require "json"
    require "uri"
    require "fileutils"
    
    def main
      case ARGV[0]
      when "help"
        puts "`help` is not yet defined"
      when "login"
        puts "`login` is not yet defined"
      when "whoami"
        puts "`whoami` is not yet defined"
      else
        puts "Unknown command `#{ARGV[0]}`"
      end
    end
    
    main
    

    ターミナルで、app_cli.rb が保存されているディレクトリから、./app_cli.rb help を実行します。 次のように出力されます。

    `help` is not yet defined
    

    また、コマンドを指定せずに、または処理されないコマンドを指定して、スクリプトをテストすることもできます。 たとえば、./app_cli.rb create-issue では次のように出力されるはずです。

    Unknown command `create-issue`
    

help コマンドを追加する

  1. 次の help 関数を app_cli.rb に追加します。 現在、help 関数は、この CLI が 1 つのコマンド "help" を受け取ることをユーザーに伝える行を出力します。 後でこの help 関数を拡張します。

    Ruby
    def help
      puts "usage: app_cli <help>"
    end
    
  2. help コマンドが指定されたら help 関数を呼び出すように main 関数を更新します。

    Ruby
    def main
      case ARGV[0]
      when "help"
        help
      when "login"
        puts "`login` is not yet defined"
      when "whoami"
        puts "`whoami` is not yet defined"
      else
        puts "Unknown command #{ARGV[0]}"
      end
    end
    
  3. 必要に応じて、進行状況をチェックします。

    ここまでで、app_cli.rb は次のようになっています。 main 関数の呼び出しがファイルの末尾にある限り、関数の順序は関係ありません。

    Ruby
    #!/usr/bin/env ruby
    
    require "net/http"
    require "json"
    require "uri"
    require "fileutils"
    
    def help
      puts "usage: app_cli <help>"
    end
    
    def main
      case ARGV[0]
      when "help"
        help
      when "login"
        puts "`login` is not yet defined"
      when "whoami"
        puts "`whoami` is not yet defined"
      else
        puts "Unknown command #{ARGV[0]}"
      end
    end
    
    main
    

    ターミナルで、app_cli.rb が保存されているディレクトリから、./app_cli.rb help を実行します。 次のように出力されます。

    usage: app_cli <help>
    

login コマンドを追加する

login コマンドは、デバイス フローを実行してユーザー アクセス トークンを取得します。 詳しくは、「GitHub アプリのユーザー アクセス トークンの生成」をご覧ください。

  1. ファイルの先頭近くにある require ステートメントの後に、app_cli.rb での定数として GitHub App の CLIENT_ID を追加します。 アプリのクライアント ID の検索の詳細については、「クライアント ID を取得する」を参照してください。 YOUR_CLIENT_ID は、実際のアプリのクライアント ID に置き換えます。

    Ruby
    CLIENT_ID="YOUR_CLIENT_ID"
    
  2. 次の parse_response 関数を app_cli.rb に追加します。 この関数は、GitHub REST API からの応答を解析します。 応答の状態が 200 OK または 201 Created の場合、関数は解析された応答本文を返します。 それ以外の場合、関数は応答と本文を出力して、プログラムを終了します。

    Ruby
    def parse_response(response)
      case response
      when Net::HTTPOK, Net::HTTPCreated
        JSON.parse(response.body)
      else
        puts response
        puts response.body
        exit 1
      end
    end
    
  3. 次の request_device_code 関数を app_cli.rb に追加します。 この関数は、https://github.com/login/device/codePOST 要求を行って、応答を返します。

    Ruby
    def request_device_code
      uri = URI("https://github.com/login/device/code")
      parameters = URI.encode_www_form("client_id" => CLIENT_ID)
      headers = {"Accept" => "application/json"}
    
      response = Net::HTTP.post(uri, parameters, headers)
      parse_response(response)
    end
    
  4. 次の request_token 関数を app_cli.rb に追加します。 この関数は、https://github.com/login/oauth/access_tokenPOST 要求を行って、応答を返します。

    Ruby
    def request_token(device_code)
      uri = URI("https://github.com/login/oauth/access_token")
      parameters = URI.encode_www_form({
        "client_id" => CLIENT_ID,
        "device_code" => device_code,
        "grant_type" => "urn:ietf:params:oauth:grant-type:device_code"
      })
      headers = {"Accept" => "application/json"}
      response = Net::HTTP.post(uri, parameters, headers)
      parse_response(response)
    end
    
  5. 次の poll_for_token 関数を app_cli.rb に追加します。 この関数は、GitHub が error パラメーターではなく access_token パラメーターで応答するまで、指定された間隔で https://github.com/login/oauth/access_token へのポーリングを行います。 その後、ユーザー アクセス トークンをファイルに書き込み、ファイルに対するアクセス許可を制限します。

    Ruby
    def poll_for_token(device_code, interval)
    
      loop do
        response = request_token(device_code)
        error, access_token = response.values_at("error", "access_token")
    
        if error
          case error
          when "authorization_pending"
            # The user has not yet entered the code.
            # Wait, then poll again.
            sleep interval
            next
          when "slow_down"
            # The app polled too fast.
            # Wait for the interval plus 5 seconds, then poll again.
            sleep interval + 5
            next
          when "expired_token"
            # The `device_code` expired, and the process needs to restart.
            puts "The device code has expired. Please run `login` again."
            exit 1
          when "access_denied"
            # The user cancelled the process. Stop polling.
            puts "Login cancelled by user."
            exit 1
          else
            puts response
            exit 1
          end
        end
    
        File.write("./.token", access_token)
    
        # Set the file permissions so that only the file owner can read or modify the file
        FileUtils.chmod(0600, "./.token")
    
        break
      end
    end
    
  6. 次の login 関数を追加します。

    この関数では、次の処理を実行します。

    1. request_device_code 関数を呼び出して、応答から verification_uriuser_codedevice_codeinterval の各パラメーターを取得します。
    2. 前のステップの user_code を入力するようユーザーに求めます。
    3. poll_for_token を呼び出して、GitHub でアクセス トークンをポーリングします。
    4. 認証が成功したことをユーザーに知らせます。
    Ruby
    def login
      verification_uri, user_code, device_code, interval = request_device_code.values_at("verification_uri", "user_code", "device_code", "interval")
    
      puts "Please visit: #{verification_uri}"
      puts "and enter code: #{user_code}"
    
      poll_for_token(device_code, interval)
    
      puts "Successfully authenticated!"
    end
    
  7. login コマンドが指定されたら login 関数を呼び出すように main 関数を更新します。

    Ruby
    def main
      case ARGV[0]
      when "help"
        help
      when "login"
        login
      when "whoami"
        puts "`whoami` is not yet defined"
      else
        puts "Unknown command #{ARGV[0]}"
      end
    end
    
  8. login コマンドを含むように help 関数を更新します。

    Ruby
    def help
      puts "usage: app_cli <login | help>"
    end
    
  9. 必要に応じて、進行状況をチェックします。

    これで、app_cli.rb は次のようになります。YOUR_CLIENT_ID はアプリのクライアント ID です。 main 関数の呼び出しがファイルの末尾にある限り、関数の順序は関係ありません。

    Ruby
    #!/usr/bin/env ruby
    
    require "net/http"
    require "json"
    require "uri"
    require "fileutils"
    
    CLIENT_ID="YOUR_CLIENT_ID"
    
    def help
      puts "usage: app_cli <login | help>"
    end
    
    def main
      case ARGV[0]
      when "help"
        help
      when "login"
        login
      when "whoami"
        puts "`whoami` is not yet defined"
      else
        puts "Unknown command #{ARGV[0]}"
      end
    end
    
    def parse_response(response)
      case response
      when Net::HTTPOK, Net::HTTPCreated
        JSON.parse(response.body)
      else
        puts response
        puts response.body
        exit 1
      end
    end
    
    def request_device_code
      uri = URI("https://github.com/login/device/code")
      parameters = URI.encode_www_form("client_id" => CLIENT_ID)
      headers = {"Accept" => "application/json"}
    
      response = Net::HTTP.post(uri, parameters, headers)
      parse_response(response)
    end
    
    def request_token(device_code)
      uri = URI("https://github.com/login/oauth/access_token")
      parameters = URI.encode_www_form({
        "client_id" => CLIENT_ID,
        "device_code" => device_code,
        "grant_type" => "urn:ietf:params:oauth:grant-type:device_code"
      })
      headers = {"Accept" => "application/json"}
      response = Net::HTTP.post(uri, parameters, headers)
      parse_response(response)
    end
    
    def poll_for_token(device_code, interval)
    
      loop do
        response = request_token(device_code)
        error, access_token = response.values_at("error", "access_token")
    
        if error
          case error
          when "authorization_pending"
            # The user has not yet entered the code.
            # Wait, then poll again.
            sleep interval
            next
          when "slow_down"
            # The app polled too fast.
            # Wait for the interval plus 5 seconds, then poll again.
            sleep interval + 5
            next
          when "expired_token"
            # The `device_code` expired, and the process needs to restart.
            puts "The device code has expired. Please run `login` again."
            exit 1
          when "access_denied"
            # The user cancelled the process. Stop polling.
            puts "Login cancelled by user."
            exit 1
          else
            puts response
            exit 1
          end
        end
    
        File.write("./.token", access_token)
    
        # Set the file permissions so that only the file owner can read or modify the file
        FileUtils.chmod(0600, "./.token")
    
        break
      end
    end
    
    def login
      verification_uri, user_code, device_code, interval = request_device_code.values_at("verification_uri", "user_code", "device_code", "interval")
    
      puts "Please visit: #{verification_uri}"
      puts "and enter code: #{user_code}"
    
      poll_for_token(device_code, interval)
    
      puts "Successfully authenticated!"
    end
    
    main
    
    1. ターミナルで、app_cli.rb が保存されているディレクトリから、./app_cli.rb login を実行します。 出力は次のようになります。 コードは毎回異なります。

      Please visit: https://github.com/login/device
      and enter code: CA86-8D94
      
    2. ブラウザーで https://github.com/login/device に移動し、前のステップのコードを入力して、 [続行] をクリックします。

    3. GitHub で、アプリの承認を求めるページが表示されます。 [承認] ボタンをクリックします。

    4. ターミナルに "Successfully authenticated!" と表示されます。

whoami コマンドを追加する

アプリでユーザー アクセス トークンを生成できるようになったので、ユーザーに代わって API 要求を行うことができます。 認証されたユーザーのユーザー名を取得する whoami コマンドを追加します。

  1. 次の whoami 関数を app_cli.rb に追加します。 この関数は、/user REST API エンドポイントでユーザーに関する情報を取得します。 ユーザー アクセス トークンに対応するユーザー名を出力します。 .token ファイルが見つからなかった場合は、login 関数を実行するようユーザーに求めます。

    Ruby
    def whoami
      uri = URI("https://api.github.com/user")
    
      begin
        token = File.read("./.token").strip
      rescue Errno::ENOENT => e
        puts "You are not authorized. Run the `login` command."
        exit 1
      end
    
      response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
        body = {"access_token" => token}.to_json
        headers = {"Accept" => "application/vnd.github+json", "Authorization" => "Bearer #{token}"}
    
        http.send_request("GET", uri.path, body, headers)
      end
    
      parsed_response = parse_response(response)
      puts "You are #{parsed_response["login"]}"
    end
    
  2. トークンが有効期限切れになったり取り消されたりしたケースを処理するように、parse_response 関数を更新します。 ここで、401 Unauthorized 応答を受け取った場合、CLI はユーザーに login コマンドの実行を求めます。

    Ruby
    def parse_response(response)
      case response
      when Net::HTTPOK, Net::HTTPCreated
        JSON.parse(response.body)
      when Net::HTTPUnauthorized
        puts "You are not authorized. Run the `login` command."
        exit 1
      else
        puts response
        puts response.body
        exit 1
      end
    end
    
  3. whoami コマンドが指定されたら whoami 関数を呼び出すように main 関数を更新します。

    Ruby
    def main
      case ARGV[0]
      when "help"
        help
      when "login"
        login
      when "whoami"
        whoami
      else
        puts "Unknown command #{ARGV[0]}"
      end
    end
    
  4. whoami コマンドを含むように help 関数を更新します。

    Ruby
    def help
      puts "usage: app_cli <login | whoami | help>"
    end
    
  5. コードを次のセクションの完全なコード例に照らしてチェックします。 コードをテストするには、完全なコード例の後の「テスト」セクションで説明されている手順のようにします。

完全なコード例

次に、前のセクションで概要を説明したコードの完全な例を示します。 YOUR_CLIENT_ID は、ご自分のアプリのクライアント ID に置き換えます。

Ruby
#!/usr/bin/env ruby

require "net/http"
require "json"
require "uri"
require "fileutils"

CLIENT_ID="YOUR_CLIENT_ID"

def help
  puts "usage: app_cli <login | whoami | help>"
end

def main
  case ARGV[0]
  when "help"
    help
  when "login"
    login
  when "whoami"
    whoami
  else
    puts "Unknown command #{ARGV[0]}"
  end
end

def parse_response(response)
  case response
  when Net::HTTPOK, Net::HTTPCreated
    JSON.parse(response.body)
  when Net::HTTPUnauthorized
    puts "You are not authorized. Run the `login` command."
    exit 1
  else
    puts response
    puts response.body
    exit 1
  end
end

def request_device_code
  uri = URI("https://github.com/login/device/code")
  parameters = URI.encode_www_form("client_id" => CLIENT_ID)
  headers = {"Accept" => "application/json"}

  response = Net::HTTP.post(uri, parameters, headers)
  parse_response(response)
end

def request_token(device_code)
  uri = URI("https://github.com/login/oauth/access_token")
  parameters = URI.encode_www_form({
    "client_id" => CLIENT_ID,
    "device_code" => device_code,
    "grant_type" => "urn:ietf:params:oauth:grant-type:device_code"
  })
  headers = {"Accept" => "application/json"}
  response = Net::HTTP.post(uri, parameters, headers)
  parse_response(response)
end

def poll_for_token(device_code, interval)

  loop do
    response = request_token(device_code)
    error, access_token = response.values_at("error", "access_token")

    if error
      case error
      when "authorization_pending"
        # The user has not yet entered the code.
        # Wait, then poll again.
        sleep interval
        next
      when "slow_down"
        # The app polled too fast.
        # Wait for the interval plus 5 seconds, then poll again.
        sleep interval + 5
        next
      when "expired_token"
        # The `device_code` expired, and the process needs to restart.
        puts "The device code has expired. Please run `login` again."
        exit 1
      when "access_denied"
        # The user cancelled the process. Stop polling.
        puts "Login cancelled by user."
        exit 1
      else
        puts response
        exit 1
      end
    end

    File.write("./.token", access_token)

    # Set the file permissions so that only the file owner can read or modify the file
    FileUtils.chmod(0600, "./.token")

    break
  end
end

def login
  verification_uri, user_code, device_code, interval = request_device_code.values_at("verification_uri", "user_code", "device_code", "interval")

  puts "Please visit: #{verification_uri}"
  puts "and enter code: #{user_code}"

  poll_for_token(device_code, interval)

  puts "Successfully authenticated!"
end

def whoami
  uri = URI("https://api.github.com/user")

  begin
    token = File.read("./.token").strip
  rescue Errno::ENOENT => e
    puts "You are not authorized. Run the `login` command."
    exit 1
  end

  response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
    body = {"access_token" => token}.to_json
    headers = {"Accept" => "application/vnd.github+json", "Authorization" => "Bearer #{token}"}

    http.send_request("GET", uri.path, body, headers)
  end

  parsed_response = parse_response(response)
  puts "You are #{parsed_response["login"]}"
end

main

テスト

このチュートリアルでは、アプリ コードが app_cli.rb という名前のファイルに格納されているものと想定しています。

  1. ターミナルで、app_cli.rb が保存されているディレクトリから、./app_cli.rb help を実行します。 出力は次のようになります。

    usage: app_cli <login | whoami | help>
    
  2. ターミナルで、app_cli.rb が保存されているディレクトリから、./app_cli.rb login を実行します。 出力は次のようになります。 コードは毎回異なります。

    Please visit: https://github.com/login/device
    and enter code: CA86-8D94
    
  3. ブラウザーで https://github.com/login/device に移動し、前のステップのコードを入力して、 [続行] をクリックします。

  4. GitHub で、アプリの承認を求めるページが表示されます。 [承認] ボタンをクリックします。

  5. ターミナルに "Successfully authenticated!" と表示されます。

  6. ターミナルで、app_cli.rb が保存されているディレクトリから、./app_cli.rb whoami を実行します。 次のような出力が表示されます。octocat はユーザー名です。

    You are octocat
    
  7. エディターで .token ファイルを開き、トークンを変更します。 これで、トークンは無効になります。

  8. ターミナルで、app_cli.rb が保存されているディレクトリから、./app_cli.rb whoami を実行します。 出力は次のようになります。

    You are not authorized. Run the `login` command.
    
  9. .token ファイルを削除します。

  10. ターミナルで、app_cli.rb が保存されているディレクトリから、./app_cli.rb whoami を実行します。 出力は次のようになります。

    You are not authorized. Run the `login` command.
    

次の手順

アプリのニーズに合わせてコードを調整する

このチュートリアルでは、デバイス フローを使ってユーザー アクセス トークンを生成する CLI を記述する方法を示しました。 追加のコマンドを受け取るように、この CLI を拡張できます。 たとえば、問題を開く create-issue コマンドを追加できます。 行う API 要求でアプリに追加のアクセス許可が必要な場合は、忘れずにアプリのアクセス許可を更新してください。 詳しくは、「GitHub アプリのアクセス許可を選択する」をご覧ください。

トークンを安全に保存する

このチュートリアルでは、ユーザー アクセス トークンを生成し、それをローカル ファイルに保存します。 このファイルをコミットしたり、トークンを公開したりしないでください。

デバイスによっては、異なる方法でトークンを保存できます。 デバイスにトークンを格納するためのベスト プラクティスを確認する必要があります。

詳しくは、「GitHub App を作成するためのベスト プラクティス」をご覧ください。

ベスト プラクティスに従う

GitHub App に関するベスト プラクティスに従うようにする必要があります。 詳しくは、「GitHub App を作成するためのベスト プラクティス」をご覧ください。