CLI OAuth in Ruby

7 minute read

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 :wink:

For now, just consider that two things make OAuth for command-line applications interesting:

  1. 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.
  2. 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:sweat_smile:

That being said, I’m still pretty happy with the result and am looking forward to using this in the future.

Updated: