Skip to main content

Easy OAuth with IO

·1258 words·6 mins
Agent IO
Author
Agent IO
Table of Contents

It started with ACME
#

You’ve probably seen that IO’s Ingress Mode can automatically get SSL certificates from LetsEncrypt. It does this using a protocol called ACME. One way that ACME verifies that a server is actually entitled to the certificate that it requests is to give the server a secret and then get that secret back from the server by requesting it on a well-known path under the hostname that the server claims to own.

IO takes care of this by intercepting those ACME challenge requests so that the backend doesn’t have to know or do anything. As far as the backend is concerned, it just has free certificates, and it might not even need to know that.

Moving OAuth to the Proxy
#

There’s something else that’s a lot like this: OAuth callbacks. Just like it does for ACME, IO creates handlers for a few significant OAuth-related paths when we register OAuth clients with our ingress. It then uses these to take care of everything OAuth-related for the ingress backend.

Configuring IO for OAuth
#

Here’s an IO ingress configuration that includes an OAuth client:

host "YOUR-HOST-NAME" {
  name    = "googledemo"
  backend = "localhost:3000"
  oauth "google" {
    client_id        = "YOUR-CLIENT-ID"
    client_secret    = "YOUR-CLIENT-SECRET"
    authorize_url    = "https://accounts.google.com/o/oauth2/auth"
    access_token_url = "https://accounts.google.com/o/oauth2/token"
    scopes           = ["profile", "email"]
    authorize_parameter {
      name = "approval_prompt"
      value = "force"
    } 
  }
}

This creates an ingress serving YOUR-HOST-NAME with the backend at localhost:3000. That’s probably familiar to you. The new thing is that we’re also declaring an OAuth client that’s going to allow us to use Google signin for our app. We’ve registed a client with Google and put the client id and client secret in the configuration, and we’ve found the other information in Google’s OAuth documentation. We’re adding approval_prompt=force to our signin queries to tell Google we want users to re-approve our site every time they sign in.

You might notice that one thing that you usually see in OAuth configurations is missing. We aren’t specifying a redirect url for the OAuth callback. IO sets this automatically from the OAuth client name. For this client, the callback path is /@google/callback. When you register your client with Google, you’ll want to set the redirect URL to https://YOUR-HOST-NAME/@google/callback.

What IO does to handle OAuth
#

IO creates handlers for a few significant OAuth-related paths and intercepts calls to these paths.

We’ve already mentioned /@google/callback. Here is the full list of handlers, where CLIENT is the id of the OAuth client in the IO configuration:

/@CLIENT/signinredirects the user to the provider’s signin page
/@CLIENT/callbackhandles the callback from the provider
/@CLIENT/signoutsigns out a user, removing the user’s saved credentials

How IO manages OAuth credentials
#

“Saved credentials?” you ask, “Where are they saved?” IO keeps user tokens in io.db. Notably, it doesn’t give them to backends. This is good for security and simplicity: your backend never needs to manage access tokens or worry about losing them. But how can it use them?

The answer is “with an IO calling interface.” Here’s a configuration:

call "google" {
  name     = "Google"
  port     = 4848
  endpoint = "www.googleapis.com"
  auth {
    name       = "authorization"
    location   = "header"
    credential = "session:google"
  }
}

This is an IO calling configuration that creates an interface for the backend to use. Our backend makes all of its calls to Google through this interface, which is configured to use the google credential from the user’s session.

But how do we know which user is signed in? User sessions are identified by the Proxy-Session header. IO sends this to the ingress backend whenever a user is signed in. When the backend makes its calls to Google, the backend just puts this Proxy-Session header in the requests that it sends to the calling interface. IO uses this to look up and apply the right token. It will even refresh an access token if it has expired.

As you might guess, this makes web apps that use OAuth a lot easier to write.

Let’s look at an example
#

As an example, we’ll look at an app that allows users to sign in with Google and then displays their Google profiles.

Here’s what we see when no one is signed in.

The app before I sign in

When a user presses “sign in”, the app sends them to /@google/signin, which redirects them to this Google signin page.

Signing in with Google

Working forward, Google asks for some extra confirmation before approving the signin. I’ve logged into this site before, so it’s just asking me if I’m ok to go back.

Approving the application

After I approve, Google redirects the browser to /@google/callback with a code that IO uses to get access and refresh tokens for the user. Then IO redirects the browser back to the app, which it serves after giving it the Proxy-Session token, which the backend uses to get my profile, which it displays below.

The app after I've signed in

If we switch over to IO, we can see the recent traffic to our ingress, which includes the redirects from the /@google/signin and /@google/callback handlers.

The ingress host in IO

Pressing the o key on this screen takes us to a list of active sessions, where we see that we have one session that is signed in with google credentials.

The ingress sessions

Selecting this session in the list takes us to session details, where you can see the tokens that IO is managing for this session (it looks like I’m leaking secrets here, but these extend much further offscreen).

Details for the current session

Is there code? Yes, but not much
#

The code for this example is on GitHub in the repo linked below:

There’s really just one source file, main.go. Here’s the good part:

	var profile Profile
	providers := r.Header.Get("proxy-provider")
	if strings.Contains(providers, "google") {
		request, err := http.NewRequest("GET", proxyUrl+"/oauth2/v3/userinfo", nil)
		if err == nil {
			request.Header.Set("proxy-session", r.Header.Get("proxy-session"))
			response, err := http.DefaultClient.Do(request)
			if err == nil {
				body, err := io.ReadAll(response.Body)
				if err == nil {
					err = json.Unmarshal(body, &profile)
				}
			}
		}
	}

This code fragment shows how our app knows if a user is signed in or not and, if so, how the app calls the Google API. The app reads the Proxy-Provider header. If that header contains google, then the app knows that a user is signed in with the google provider that was defined in the ingress configuration. If a user is signed in, the app then calls the /oauth2/v3/userinfo endpoint through IO, copying the Proxy-Session value from its request headers to the headers of its calling mode request to IO.

Yeah, that’s really all it takes. And if you didn’t notice, there are no OAuth-specific libraries in this code. You can write your ingress backend in any programming language that can accept and make HTTP requests.

The footprints in the butter
#

This was so easy that you might be tempted to start adding Google auth to everything. But the elephant in the refrigerator is that it is still a hassle to get client credentials from Google. Google expects you to register each of your applications separately, get them approved by someone at Google before you operate at scale, and of course you’ve got to deal with those ugly client id and client secret strings. This, respectfully, is a pain in the ass, especially if all that you want to do is get a way to identify your logged-in users. And you know, maybe your users don’t want Google tracking all the apps where they sign in.

Soon we’ll discuss ATProto Auth, and when you see it, I think you’ll agree that login with Google (or any other traditional OAuth provider) is dead.