Login via command line

I’m working on a command-line tool that will require user login, so I wanted to have the flow that all the snazzy command-line clis use: pop up a browser window and ask you to login with <known provider>, then pass back something to the command line. Unfortunately, I had no idea what this type of login was called or how to do it.

This article was great, and went through enough of the flow that I got the idea and finished it up on my own. The (kind of ridiculous) flow is:

  • Start a local webserver.
  • Open a browser pointing to the platform you want to use to login.
  • …passing the local webserver’s address in as the redirect for post-login.
  • Receive the response on the webserver and parse it.

I’m using Python, so in more detail: first we start a local webserver. I’m doing this in a separate thread, because I need to do some other work while the webserver is handling stuff.

import http.server
import threading

class LoginManager:
  def __init__(self):
    self._server = None
    self._port = 0

  def start_web_server(self):
    """Kick off a thread for the local webserver."""
    th = threading.Thread(target=self._start_local_server)
    th.start()

  def _start_local_server(self):
    self._server = http.server.HTTPServer(('localhost', 0), Handler)
    self._port = self._server.server_port
    print(f'Serving on port {self._port}')
    self._server.serve_forever()

_start_local_server is the interesting part here. I don’t want to risk bumping into a port conflict (imagine how confusing it would be to not be able to log into a website because you happened to be running some emulator), so I’m going to make the OS give us an open port. Also, we only want the server to listen to localhost (no outside traffic). The pair ('localhost', 0) is the host and port, which binds the server to only accept requests to localhost and says “give me an open port.”

Because we’re not specifying a port, we then have to figure out what port we’re using. So I immediately ask the server what port was chosen (and then print it, for my own debugging).

Next up, we need to open a browser.

  def open_browser(self):
    """Opens the browser to the login page."""
    # Waits for the server to start.
    while self._server is None:
      time.sleep(1)
    url = self.create_login_url()
    system = platform.system()
    if system == 'Darwin:
      cmd = ['open', url]
    elif system == 'Linux':
      cmd = ['xdg-open', url]
    elif system == 'Windows':
      cmd = ['cmd', '/c', 'start', url.replace('&', '^&')]
    else:
      raise RuntimeError(f'Unsupported system: {system}')
    subprocess.run(cmd, check=True)

This is just copied from the article I linked above. I’m on Darwin and it works great, YMMV.

The URL is returned from create_login_url. This is where the article leaves us to our own devices. My default device is “Google probably has a free service that does this,” which seems to be true for this case. I created a new client credential under “OAuth 2.0 Client IDs”

In the client’s configuration you have to specify “Authorized JavaScript origins” and “Authorized redirect URIs.” We want URIs that match http://localhost:N, where N is going to change each run. However, the ? sternly warns you against URIs containing wildcards, so how do we specify N? The answer is: don’t. Turns out this is a prefix match, so put “http://localhost&#8221; in the questionable-named “URIs 1” for each section. This does mean that you have to serve the redirect from root (e.g., you have to redirect to localhost:12345, not localhost:12345/login-success-page), but this is just a scratch server for handling this one request, so that shouldn’t be a huge deal.

Armed with this configuration, we can now implement the URL gen function:

  def create_login_url(self) -> str:
    """Generate the login URL."""
    nonce = hashlib.sha256(os.urandom(1024)).hexdigest()
    return (
      'https://accounts.google.com/o/oauth2/v2/auth?'
      'response_type=code&'
      f'client_id={_CLIENT_ID}&'
      'scope=openid%20email&'
      f'redirect_uri=http%3A//localhost:{self._port}&'
      f'nonce={nonce}')

Another stern warning that we’re ignoring is that the docs “highly recommend” passing a “state” parameter. The docs assume you’re using this flow to have users log into your website, so your server has to be cautious that it’s getting a response from your actual user, not a man-in-the-middle attacker. However, we are running this direct from command line to Google, so using the state doesn’t make a lot of sense.

The final piece is to actually handle that redirect request from the browser. The browser passes back the ID token as a base64-encoded cookie, so we can use Python’s built-in libraries to extract it:

class Handler(http.server.SimpleHTTPRequestHandler):
  """Handle the response from accounts.google.com."""

  # Sketchy static variable to hold response.
  info = None

  def do_GET(self):
    c = http.cookies.SimpleCookie(self.headers.get('Cookie'))
    jwt = c['idToken'].value
    if not jwt:
      # If the server gets a non-login request.
      return
    # Google's cookie comes in the format: "[header].[idToken]."
    # where [header] and [idToken] are base64 encoded. However,
    # "." isn't a base64 thing, so we have to split up the 
    # cookie before decoding.
    pieces = jwt.split('.')
    info = None
    for piece in pieces:
      # The base64 might not have enough padding for Python's
      # decoder to roll with (JS is fine with it, but Python
      # needs a couple of extra trailing =s).
      i = base64.b64decode(f'{piece}==').decode('utf-8')
      info = json.loads(i)
      if is_header(info):
        continue
      # Otherwise, "info" is the value we want! Actually do
      # something with it here:
      do_something_with(info)
      break
    self.wfile.write(b'All set, feel free to close this tab')

def is_header(info: dict[str, Any]) -> bool:
  return 'alg' in info and 'typ' in info

This is full of gross little implementation details. I’ve tried to comment on them above. info looks something like:

{
  'name': 'Alice Doe', 
  'email': 'adoe@example.com', 
  'email_verified': True, 
  'auth_time': 1659727260, 
  'user_id': '...', 
  'firebase': {
    'identities': {
      'email': ['adoe@example.com'], 
      'google.com': ['<long string>']
    }, 
    'sign_in_provider': 'google.com'
  }, 
  'iat': 1659727260, 
  'exp': 1659730860, 
  'aud': 'lien-288519', 
  'iss': 'https://securetoken.google.com/<your project>', 
  'sub': '...'
}

Then we just have to put this all together:

def main(argv):
  m = LoginManager()
  m.start_web_server()
  m.open_browser()
  # Probably do something with info here, and shutdown the 
  # server.

if __name__ == '__main__':
  app.run(main)

Now when we run, it’ll create a server, pop open a browser, wait for us to log in, then redirect back to the server we just started, show a polite message to the user in the browser, and we can do something with the user’s token.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: