CLI OAuth in Ruby
Have you ever used a command-line application that triggered an OAuth authentication flow to log you in and wondered how that works? For example, Google Cloud SDK does this, as does Heroku CLI.
I’ve always found that a pretty neat way to handle authorization on the command line because it feels so effortless to the user. You run a command, your browser opens, you log in like you would on a website, and Bam!, you’re logged in on the command line.
So how does that work?
To find out, we’ll build a simple Thor app that supports OAuth login with Google. If you don’t care about words and just want to see the code you can find it on GitHub.
This post assumes you are somewhat familiar with OAuth. Also, the demo app we are building here should be considered a proof-of-concept. There are lots of holes and rough edges that still need ironing out before it can be used productively.
Basics
OAuth can be a bit complicated. I’m not going to get into any details - there are tons of articles explaining it much better than I could. If you need a refresher I’m sure you can find some detailed information on the web.
For now, just consider that two things make OAuth for command-line applications interesting:
- You do not own a trusted domain. The component that is starting the OAuth flow is a command-line application. There simply is no webpage to redirect the authentication provider to.
- The client itself is untrusted. You do not own the platform where the code initiating the OAuth flow is running. Similar to a mobile app, you must assume that you cannot keep secrets secret, and as such, your OAuth flow cannot use a client secret.
The first issue we can solve by starting a local server that we can redirect to. So, localhost
becomes our callback domain. When authorizing with Google, this is already accounted for when we create OAuth credentials for Desktop applications.
To solve the second issue we’ll use the PKCE extension for OAuth. This aspect of OAuth, and the security implications of not being able to keep the client secret a secret, is a bit complicated. This Okta post does a good job of explaining why PKCE works as a solution.
Creating the OAuth Client
Let’s start by creating a simple command-line application. Our app will only provide two commands: A login
command, which triggers the OAuth flow, and a user
command, which performs an authorized request to retrieve some information from the Google API.
We’ll use Thor to create the app:
class Error < Thor::Error; end
class Main < Thor
desc 'login', 'Login with Google'
def login
# TODO: Login code
end
desc 'user', 'Retrieve user data'
def user
# TODO: API Request
end
end
Before we can implement the OAuth flow we need to create OAuth Client IDs in the Google Cloud Console. If you are starting with a new project, you must create a new consent screen first.
Fill in the required information - you do not need to provide authorized domains or app domains. When selecting scopes we only need the userinfo.profile
scope, as that is the only information we want access to.
Head over to credentials and create new OAuth client ID
credentials. As application type select Desktop app
. Take note of both client ID and secret, you’ll need them later
‘Didn’t you just say we can’t use client secrets on untrusted platforms?’ I hear you say. Well, yes, but it seems that Google is a bit, like, doing their own thing here. Even though the desktop client goes through a PKCE flow, it must still provide a client secret and that secret is essentially treated as public information. This SO comment sheds some light on this weird situation.
Implementing the OAuth Flow
As mentioned previously, to receive callbacks from the authorization server, we need to start a local server to receive those callbacks. Let’s create it.
require 'socket'
require 'uri'
require 'cgi'
module Goggleme
class Server
def initialize(state)
@state = state
end
def start
server = TCPServer.new 9876
while connection = server.accept
request = connection.gets
data = handle(request)
connection.puts 'OAuth request received. You can close this window now.'
connection.close
return data if data
end
end
private
def handle(request)
_, full_path = request.split(' ')
path = URI(full_path).path
handle_authorize(full_path) if path == '/authorize'
end
def handle_authorize(full_path)
params = CGI.parse(URI.parse(full_path).query)
raise(Error, 'Invalid oauth request received') if @state != params['state'][0]
params['code'][0]
end
end
end
Executing this will start a server on port 9876
that listens for requests to the /authorize
endpoint. Upon receiving such a request, we verify that it contains the correct parameters and return the authorization code.
After the local server is ready to receive requests we need to open the Browser to allow the user to login using the selected authentication provider - in our case Google. Because we use PKCE, there is a small twist. We need to create a code_verifier
and a code_challenge
additionally to the state
.
state = SecureRandom.base64(16)
code_verifier = SecureRandom.base64(64).tr('+/', '-_').tr('=', '')
code_challenge = Digest::SHA2.base64digest(code_verifier).tr('+/', '-_').tr('=', '')
We can then start the server in a background thread.
server = Thread.new do
Thread.current.report_on_exception = false
Server.new(state).start
end
We can use state
and code_challenge
to initialize the OAuth flow. Note that we are using the code
response type and the S256
code challenge method.
We’ll use Launchy to open the browser window, and after that is done, we wait for the local server to receive the callback.
params = {
response_type: 'code',
code_challenge_method: 'S256',
code_challenge: code_challenge,
client_id: '591376582274-ctrjhsj8fjjhn4pk1rknfvcfhrcc3af7.apps.googleusercontent.com',
redirect_uri: 'http://localhost:9876/authorize',
scope: 'https://www.googleapis.com/auth/userinfo.profile',
state: state,
access_type: 'offline'
}.map { |x, v| "#{x}=#{v}" }.reduce { |x, v| "#{x}&#{v}" }
Launchy.open("https://accounts.google.com/o/oauth2/v2/auth?#{params}") do |exception|
raise(Error, "Attempted to open #{uri} and failed because #{exception}")
end
server.join
Once we have received the authorization code, we contact the authorization server to exchange it for an authorization token.
code = server.value
uri = URI('https://oauth2.googleapis.com/token')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
request = Net::HTTP::Post.new(uri)
request['content-type'] = 'application/x-www-form-urlencoded'
params = {
grant_type: 'authorization_code',
code_verifier: code_verifier,
code: code,
client_id: '591376582274-ctrjhsj8fjjhn4pk1rknfvcfhrcc3af7.apps.googleusercontent.com',
client_secret: 'cZAXyEkeV9kZNmDQyZsNLHaj',
redirect_uri: 'http://localhost:9876/authorize'
}.map { |x, v| "#{x}=#{v}" }.reduce { |x, v| "#{x}&#{v}" }
request.body = params
response = http.request(request)
raise(Error, "Invalid token response, got #{response.code}") unless response.code == '200'
If all goes well we should receive an access token along with additional data - which we’ll ignore for now to keep things simple :grin:
As you probably know, authorization tokens issued via OAuth expire after some time. The lifetime of the authorization token is part of that ‘additional data’, and would normally be used to have the user reauthorize your application.
Performing Authorized Requests
Now we’ll use the token we just received in our user
command. We simply dump it in a file at the end of the login
command and retrieve it when we need it. This is not the right way to store credentials but it will do for now.
data = JSON.parse(response.body)
path = File.join(Dir.home, '.googleme')
File.open(path, 'w') { |f| f.write data.to_json }
path = File.join(Dir.home, '.googleme')
raise(Error, 'No access token found, please login first') unless File.file?(path)
data = JSON.parse(File.read(path))
raise(Error, 'No access token found, please login first') unless data
Now the only thing that remains is to retrieve user information:
access_token = data['access_token']
uri = URI('https://www.googleapis.com/oauth2/v1/userinfo?alt=json')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
request = Net::HTTP::Get.new(uri)
request['Authorization'] = "Bearer #{access_token}"
response = http.request(request)
raise(Error, "Invalid token response, got #{response.code}") unless response.code == '200'
puts JSON.parse(response.body)
And that’s it! Running this little demo should now give you the user data of the authorized user.
# Login first
$ googleme login
# Show me the profile info!
$ googleme user
{
"id" => "123455",
"name" => "Hans Schnedlitz",
"given_name" => "Hans",
"family_name" => "Schnedlitz",
"picture" => "https://lh3.googleusercontent.com/a-/xyz",
"locale" => "en"
}
Conclusion
This was a fun little exercise that needed way more research than I expected. I learned a thing or two about OAuth that I didn’t know before, and I hope you did too while reading this. As mentioned before, the implementation is a very rough prototype and there are a bunch of things that can be improved.
Taking care of token expiry and re-authentication for one. You also should not store credentials the way I did, but rather take advantage of secure vaults that your operating system provides. And last but not least, this prototype implementation’s error handling is practically non-existent, so that should probably be changed.
That being said, I’m still pretty happy with the result and am looking forward to using this in the future.