Skip to content

Commit

Permalink
Use new Oauth flow
Browse files Browse the repository at this point in the history
The username/password exchange mechanism is (rightfully) deprecated, the
device flow is now in beta, and seems to be the perfect replacement.

This change includes a best-guess of how this might work with GitHub
Enterprise but I haven't tested that, so GitHub Enterprise will continue
to default to the deprecated flow.

Fixes #315
  • Loading branch information
ConradIrwin committed Jul 30, 2020
1 parent 75fd67a commit 894693a
Show file tree
Hide file tree
Showing 3 changed files with 105 additions and 13 deletions.
23 changes: 20 additions & 3 deletions README.md
Expand Up @@ -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.
Expand Down
78 changes: 73 additions & 5 deletions lib/gist.rb
Expand Up @@ -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 = {
Expand All @@ -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})"

Expand Down Expand Up @@ -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: "
Expand Down Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions spec/ghe_spec.rb
Expand Up @@ -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]
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down

0 comments on commit 894693a

Please sign in to comment.