Introduction
This tutorial demonstrates how to build a command line interface (CLI) backed by a GitHub App, and how to use the device flow to generate a user access token for the app.
The CLI will have three commands:
help: Outputs the usage instructions.login: Generates a user access token that the app can use to make API requests on behalf of the user.whoami: Returns information about the logged in user.
This tutorial uses Ruby, but you can write a CLI and use the device flow to generate a user access token with any programming language.
About device flow and user access tokens
The CLI will use the device flow to authenticate a user and generate a user access token. Then, the CLI can use the user access token to make API requests on behalf of the authenticated user.
Your app should use a user access token if you want to attribute the app's actions to a user. For more information, see Authenticating with a GitHub App on behalf of a user.
There are two ways to generate a user access token for a GitHub App: web application flow and device flow. You should use the device flow to generate a user access token if your app is headless or does not have access to a web interface. For example, CLI tools, simple Raspberry Pis, and desktop applications should use the device flow. If your app has access to a web interface, you should use web application flow instead. For more information, see Generating a user access token for a GitHub App and Building a "Login with GitHub" button with a GitHub App.
Prerequisites
This tutorial assumes that you have already registered a GitHub App. For more information about registering a GitHub App, see Registering a GitHub App.
Before following this tutorial, you must enable device flow for your app. For more information about enabling device flow for your app, see Modifying a GitHub App registration.
This tutorial assumes that you have a basic understanding of Ruby. For more information, see Ruby.
Get the client ID
You will need your app's client ID in order to generate a user access token via the device flow.
-
In the upper-right corner of any page on GitHub, click your profile picture.
-
Navigate to your account settings.
- For an app owned by a personal account, click Settings.
- For an app owned by an organization:
- Click Your organizations.
- To the right of the organization, click Settings.
-
In the left sidebar, click Developer settings.
-
In the left sidebar, click GitHub Apps.
-
Next to the GitHub App that you want to work with, click Edit.
-
On the app's settings page, find the client ID for your app. You will use it later in this tutorial. Note that the client ID is different from the app ID.
Write the CLI
These steps lead you through building a CLI and using device flow to get a user access token. To skip ahead to the final code, see Full code example.
Setup
-
Create a Ruby file to hold the code that will generate a user access token. This tutorial will name the file
app_cli.rb. -
In your terminal, from the directory where
app_cli.rbis stored, run the following command to makeapp_cli.rbexecutable:Text chmod +x app_cli.rb
chmod +x app_cli.rb -
Add this line to the top of
app_cli.rbto indicate that the Ruby interpreter should be used to run the script:Ruby #!/usr/bin/env ruby
#!/usr/bin/env ruby -
Add these dependencies to the top of
app_cli.rb, following#!/usr/bin/env ruby:Ruby require "net/http" require "json" require "uri" require "fileutils"
require "net/http" require "json" require "uri" require "fileutils"These are all part of the Ruby standard library, so you don't need to install any gems.
-
Add the following
mainfunction that will serve as an entry point. The function includes acasestatement to take different actions depending on which command is specified. You will expand thiscasestatement later.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 enddef 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 -
At the bottom of the file, add the following line to call the entry point function. This function call should remain at the bottom of your file as you add more functions to this file later in the tutorial.
Ruby main
main -
Optionally, check your progress:
app_cli.rbnow looks like this: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#!/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 mainIn your terminal, from the directory where
app_cli.rbis stored, run./app_cli.rb help. You should see this output:`help` is not yet definedYou can also test your script without a command or with an unhandled command. For example,
./app_cli.rb create-issueshould output:Unknown command `create-issue`
Add a help command
-
Add the following
helpfunction toapp_cli.rb. Currently, thehelpfunction prints a line to tell users that this CLI takes one command, "help". You will expand thishelpfunction later.Ruby def help puts "usage: app_cli <help>" end
def help puts "usage: app_cli <help>" end -
Update the
mainfunction to call thehelpfunction when thehelpcommand is given: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 enddef 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 -
Optionally, check your progress:
app_cli.rbnow looks like this. The order of the functions doesn't matter as long as themainfunction call is at the end of the file.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#!/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 mainIn your terminal, from the directory where
app_cli.rbis stored, run./app_cli.rb help. You should see this output:usage: app_cli <help>
Add a login command
The login command will run the device flow to get a user access token. For more information, see Generating a user access token for a GitHub App.
-
Near the top of your file, after the
requirestatements, add theCLIENT_IDof your GitHub App as a constant inapp_cli.rb. For more information about finding your app's client ID, see Get the client ID. ReplaceYOUR_CLIENT_IDwith the client ID of your app:Ruby CLIENT_ID="YOUR_CLIENT_ID"
CLIENT_ID="YOUR_CLIENT_ID" -
Add the following
parse_responsefunction toapp_cli.rb. This function parses a response from the GitHub REST API. When the response status is200 OKor201 Created, the function returns the parsed response body. Otherwise, the function prints the response and body and exits the program.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 enddef parse_response(response) case response when Net::HTTPOK, Net::HTTPCreated JSON.parse(response.body) else puts response puts response.body exit 1 end end -
Add the following
request_device_codefunction toapp_cli.rb. This function makes aPOSTrequest tohttps://github.com/login/device/codeand returns the response.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) enddef 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 -
Add the following
request_tokenfunction toapp_cli.rb. This function makes aPOSTrequest tohttps://github.com/login/oauth/access_tokenand returns the response.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) enddef 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 -
Add the following
poll_for_tokenfunction toapp_cli.rb. This function pollshttps://github.com/login/oauth/access_tokenat the specified interval until GitHub responds with anaccess_tokenparameter instead of anerrorparameter. Then, it writes the user access token to a file and restricts the permissions on the file.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 enddef 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 -
Add the following
loginfunction.This function:
- Calls the
request_device_codefunction and gets theverification_uri,user_code,device_code, andintervalparameters from the response. - Prompts users to enter the
user_codefrom the previous step. - Calls the
poll_for_tokento poll GitHub for an access token. - Lets the user know that authentication was successful.
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!" enddef 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 - Calls the
-
Update the
mainfunction to call theloginfunction when thelogincommand is given: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 enddef 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 -
Update the
helpfunction to include thelogincommand:Ruby def help puts "usage: app_cli <login | help>" end
def help puts "usage: app_cli <login | help>" end -
Optionally, check your progress:
app_cli.rbnow looks something like this, whereYOUR_CLIENT_IDis the client ID of your app. The order of the functions doesn't matter as long as themainfunction call is at the end of the file.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#!/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