Last week I wrote a tool to find a Github user’s e-mail address from their commits. One of the annoyances of the original implementation was the need to manually generate a personal Github access token and store it. However, I’ve seen other tools (eg. ghi) ask for credentials and store the token in the OSX keychain. How does one accomplish that? We’re going to improve a bit upon ghi’s code.
Prompt for a Username and Password
Reading a username is fairly straightforward with $stdin.gets.chomp
. We can improve a bit upon it and fetch it from git config
instead.
def username
@username ||= begin
username = `git config github.user`.chomp
username = get_username if username.empty?
username
end
end
def get_username
print 'Enter GithHub username: '
$stdin.gets.chomp
rescue Interrupt => e
raise e, 'ctrl + c'
end
We don’t want to echo passwords.
def password
@password ||= get_password
end
def get_password
print "Enter #{username}'s GitHub password (never stored): "
get_secure
end
def get_secure
current_tty = `stty -g`
system 'stty raw -echo -icanon isig' if $CHILD_STATUS.success?
input = ''
while (char = $stdin.getbyte) && !((char == 13) || (char == 10))
if (char == 127) || (char == 8)
input[-1, 1] = '' unless input.empty?
else
$stdout.write '*'
input << char.chr
end
end
print "\r\n"
input
rescue Interrupt => e
raise e, 'ctrl + c'
ensure
system "stty #{current_tty}" unless current_tty.empty?
end
Note how we change stty, support backspace, echo *
and bail on Ctrl + C.
Authenticate Against Github with 2FA
We can use github_api to authenticate against Github with a username and password.
Github.new do |config|
config.basic_auth = [username, password].join(':')
end
However, most users now hopefully have two-factor authentication enabled. Github auth will fail with Github::Error::Unauthorized
and return a X-GitHub-OTP
header with the value of required; app
to signal that a 2FA code is required. The latter will need to be sent back in the X-GitHub-OTP
header.
def github(code = nil)
Github.new do |config|
config.basic_auth = [username, password].join(':')
if code
config.connection_options = {
headers: {
'X-GitHub-OTP' => code
}
}
end
end
end
Create a Github Token
To create a token we supply a note that uniquely identifies it on the Github personal tokens page. Once created tokens cannot be retrieved, so we will store the value locally. To uniquely identify tokens we include the local host name in the note.
def note
"MyApp on #{Socket.gethostname}"
end
We recurse with 2FA until a token can be successfully created with auth.create
or an error occurs. One such interesting error is when trying to create a token that already exists. Since token values cannot be obtained after creation, we must tell the user to delete the token manually. And we don’t want to delete the token automatically because it will possibly break another app instance that has created it.
def get_code
print 'Enter GitHub 2FA code: '
get_secure
end
def github_token(code = nil)
github(code).auth.create(scopes: ['public_repo'], note: note).token
rescue Github::Error::Unauthorized => e
case e.response_headers['X-GitHub-OTP']
when /required/ then
github_token(get_code)
else
raise e
end
rescue Github::Error::UnprocessableEntity => e
raise e, 'A token already exists! Please revoke it from https://github.com/settings/tokens.'
end
Storing in Keychain
We use the command-line security add-internet-password
tool to store Internet passwords in Keychain and security find-internet-password
to retrieve one.
def store!(options)
system security('add', options)
end
def get(options)
system security('find', options)
end
def security(command = nil, options = nil)
run = ['security']
run << "#{command}-internet-password"
run << "-a #{options[:username]}"
run << "-s #{options[:server]}"
if command == 'add'
run << "-l #{options[:label]}"
run << '-U'
run << "-w #{options[:password]}" if options.key?(:password)
else
run << '-w'
end
run.join ' '
end
Putting It Together
$ fue find defunkt
Enter dblock's GitHub password (never stored): ******************
Enter GitHub 2FA code: ******
Token saved to keychain.
Chris Wanstrath <chris@ozmm.org>
Chris Wanstrath <chris@github.com>
Running the tool the second time no longer prompts for credentials!
See fue@6937a4 for the rest of implementation details.