diff --git a/README.md b/README.md index a22ffcb..c55f322 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,26 @@ To read a gist and print it to STDOUT ## Login -If you want to associate your gists with your GitHub account, you need to login -with gist. It doesn't store your username and password, it just uses them to get -an OAuth2 token (with the "gist" permission). +Before you use `gist` for the first time you will need to log in. There are two supported login flows: + +1. The Github device-code Oauth flow. This is the default for authenticating to github.com, and can be enabled for Github Enterprise by creating an Oauth app, and exporting the environment variable `GIST_CLIENT_ID` with the client id of the Oauth app. +2. The (deprecated) username and password token exchange flow. This is the default for GitHub Enterprise, and can be used to log into github.com by exporting the environment variable `GIST_USE_USERNAME_AND_PASSWORD`. + +### The device-code flow + +This flow allows you to obtain a token by logging into GitHub in the browser and typing a verification code. This is the preferred mechanism. + + gist --login + Requesting login parameters... + Please sign in at https://github.com/login/device + and enter code: XXXX-XXXX + Success! https://github.com/settings/connections/applications/4f7ec0d4eab38e74384e + +The returned access_token is stored in `~/.gist` and used for all future gisting. If you need to you can revoke access from https://github.com/settings/connections/applications/4f7ec0d4eab38e74384e. + +### The username-password flow + +This flow asks for your GitHub username and password (and 2FA code), and exchanges them for a token with the "gist" permission (your username and password are not stored). This mechanism is deprecated by GitHub, but may still work with GitHub Enterprise. gist --login Obtaining OAuth2 access_token from GitHub. diff --git a/lib/gist.rb b/lib/gist.rb index b96d05d..869a303 100644 --- a/lib/gist.rb +++ b/lib/gist.rb @@ -12,7 +12,7 @@ module Gist extend self - VERSION = '5.1.0' + VERSION = '6.0.0' # A list of clipboard commands with copy and paste support. CLIPBOARD_COMMANDS = { @@ -23,12 +23,16 @@ module Gist } GITHUB_API_URL = URI("https://api.github.com/") + GITHUB_URL = URI("https://github.com/") GIT_IO_URL = URI("https://git.io") GITHUB_BASE_PATH = "" GHE_BASE_PATH = "/api/v3" + GITHUB_CLIENT_ID = '4f7ec0d4eab38e74384e' + URL_ENV_NAME = "GITHUB_URL" + CLIENT_ID_ENV_NAME = "GIST_CLIENT_ID" USER_AGENT = "gist/#{VERSION} (Net::HTTP, #{RUBY_DESCRIPTION})" @@ -329,15 +333,71 @@ def rawify(url) # Log the user into gist. # + def login!(credentials={}) + if (login_url == GITHUB_URL || ENV.key?(CLIENT_ID_ENV_NAME)) && credentials.empty? && !ENV.key?('GIST_USE_USERNAME_AND_PASSWORD') + device_flow_login! + else + access_token_login!(credentials) + end + end + + def device_flow_login! + puts "Requesting login parameters..." + request = Net::HTTP::Post.new("/login/device/code") + request.body = JSON.dump({ + :scope => 'gist', + :client_id => client_id, + }) + request.content_type = 'application/json' + request['accept'] = "application/json" + response = http(login_url, request) + + if response.code != '200' + raise Error, "HTTP #{response.code}: #{response.body}" + end + + body = JSON.parse(response.body) + + puts "Please sign in at #{body['verification_uri']}" + puts " and enter code: #{body['user_code']}" + device_code = body['device_code'] + interval = body['interval'] + + loop do + sleep(interval.to_i) + request = Net::HTTP::Post.new("/login/oauth/access_token") + request.body = JSON.dump({ + :client_id => client_id, + :grant_type => 'urn:ietf:params:oauth:grant-type:device_code', + :device_code => device_code + }) + request.content_type = 'application/json' + request['Accept'] = 'application/json' + response = http(login_url, request) + if response.code != '200' + raise Error, "HTTP #{response.code}: #{response.body}" + end + body = JSON.parse(response.body) + break unless body['error'] == 'authorization_pending' + end + + if body['error'] + raise Error, body['error_description'] + end + + AuthTokenFile.write JSON.parse(response.body)['access_token'] + + puts "Success! #{ENV[URL_ENV_NAME] || "https://github.com/"}settings/connections/applications/#{client_id}" + end + + # Logs the user into gist. + # # This method asks the user for a username and password, and tries to obtain # and OAuth2 access token, which is then stored in ~/.gist # # @raise [Gist::Error] if something went wrong - # @param [Hash] credentials login details - # @option credentials [String] :username - # @option credentials [String] :password # @see http://developer.github.com/v3/oauth/ - def login!(credentials={}) + def access_token_login!(credentials={}) puts "Obtaining OAuth2 access_token from GitHub." loop do print "GitHub username: " @@ -548,11 +608,19 @@ def base_path ENV.key?(URL_ENV_NAME) ? GHE_BASE_PATH : GITHUB_BASE_PATH end + def login_url + ENV.key?(URL_ENV_NAME) ? URI(ENV[URL_ENV_NAME]) : GITHUB_URL + end + # Get the API URL def api_url ENV.key?(URL_ENV_NAME) ? URI(ENV[URL_ENV_NAME]) : GITHUB_API_URL end + def client_id + ENV.key?(CLIENT_ID_ENV_NAME) ? URI(ENV[CLIENT_ID_ENV_NAME]) : GITHUB_CLIENT_ID + end + def legacy_private_gister? return unless which('git') `git config --global gist.private` =~ /\Ayes|1|true|on\z/i diff --git a/spec/ghe_spec.rb b/spec/ghe_spec.rb index 5d88a2f..9279915 100644 --- a/spec/ghe_spec.rb +++ b/spec/ghe_spec.rb @@ -5,10 +5,10 @@ MOCK_USER = 'foo' MOCK_PASSWORD = 'bar' - MOCK_AUTHZ_GHE_URL = "#{MOCK_GHE_PROTOCOL}://#{MOCK_USER}:#{MOCK_PASSWORD}@#{MOCK_GHE_HOST}/api/v3/" + MOCK_AUTHZ_GHE_URL = "#{MOCK_GHE_PROTOCOL}://#{MOCK_GHE_HOST}/api/v3/" MOCK_GHE_URL = "#{MOCK_GHE_PROTOCOL}://#{MOCK_GHE_HOST}/api/v3/" - MOCK_AUTHZ_GITHUB_URL = "https://#{MOCK_USER}:#{MOCK_PASSWORD}@api.github.com/" MOCK_GITHUB_URL = "https://api.github.com/" + MOCK_LOGIN_URL = "https://github.com/" before do @saved_env = ENV[Gist::URL_ENV_NAME] @@ -20,8 +20,15 @@ # stub requests for /authorizations stub_request(:post, /#{MOCK_AUTHZ_GHE_URL}authorizations/). to_return(:status => 201, :body => '{"token": "asdf"}') - stub_request(:post, /#{MOCK_AUTHZ_GITHUB_URL}authorizations/). + stub_request(:post, /#{MOCK_GITHUB_URL}authorizations/). + with(headers: {'Authorization'=>'Basic Zm9vOmJhcg=='}). to_return(:status => 201, :body => '{"token": "asdf"}') + + stub_request(:post, /#{MOCK_LOGIN_URL}login\/device\/code/). + to_return(:status => 200, :body => '{"interval": "0.1", "user_code":"XXXX-XXXX", "device_code": "xxxx", "verification_uri": "https://github.com/login/device"}') + + stub_request(:post, /#{MOCK_LOGIN_URL}login\/oauth\/access_token/). + to_return(:status => 200, :body => '{"access_token":"zzzz"}') end after do @@ -48,7 +55,7 @@ Gist.login! - assert_requested(:post, /#{MOCK_AUTHZ_GITHUB_URL}authorizations/) + assert_requested(:post, /#{MOCK_LOGIN_URL}login\/oauth\/access_token/) end it "should access to #{MOCK_GHE_HOST} when $#{Gist::URL_ENV_NAME} was set" do @@ -65,7 +72,7 @@ $stdin = StringIO.new "#{MOCK_USER}_wrong\n#{MOCK_PASSWORD}_wrong\n" Gist.login! :username => MOCK_USER, :password => MOCK_PASSWORD - assert_requested(:post, /#{MOCK_AUTHZ_GITHUB_URL}authorizations/) + assert_requested(:post, /#{MOCK_GITHUB_URL}authorizations/) end end