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/signin | redirects the user to the provider’s signin page |
/@CLIENT/callback | handles the callback from the provider |
/@CLIENT/signout | signs 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.

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

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.

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.

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.

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.

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).

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.