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 を作成するためのベスト プラクティス」を参照してください。