Learning OAuth

If you already know about OAuth, this blog post isn't for you. I'm not gonna share anything revolutionary or innovative about OAuth. This is a dumbed down summary of how OAuth works, if you are interested in that. The goal of this post is just for me to consolidate the basics that I've learned from implementing the Bluesky OAuth flow on my own, and act as a proof of having learned said knowledge.

Context

Well, I'm currently unemployed, and while I'm spending half of my time trying to correct that, I'm also taking the chance to invest time in all of my other software-related personal projects that I had rattling around my head and couldn't find time to work in while employed.

One of these is creating a Bluesky client that caters to my personal needs. Specifically, I wanted it to:

Avoid relying on an AppView at all (I know it's possible, I've seen proofs of work), by querying PDSs directly and using constellation for easily finding backlinks

Have personal preference UI features like applying Markdown formatting to Bluesky posts and showing reply chains in the timeline like tumbr-style reblogs

Learn TypeScript or some other technology in the process, so I can show it off in interviews maybe

I'm admittedly not learning much TypeScript, but on the other hand, I also decided to do the backend in Go, as a way to learn that language as well (which I did use briefly for a couple of months like 5~6 years ago, but never since.) And I am learning A LOT about Go. Among other things, I'm really enjoying the many incredible niceties that Go has in its base libraries for HTTP communication and dealing with JSON data.

OAuth flow

We've all probably used it at some point, it goes like this: you request to log in on an app, you get redirected to the login screen on your server, then your server redirects you back to the app through a "callback" (hence its name) confirming it's still the same app login session you started.

Generate a token (for CSRF protection)

So the first thing we need is generate a random token to identify the request. This will be sent with the redirection to your server login page, so the server will be able to let your app know, when it gets back to your app, which request flow started this process. This is called CSRF protection, with the acronym standing for "cross-site request forgery," because otherwise, some other adversarial agent could wait for your app to do a login redirection and then do a callback request before the actual server does the real one, tricking the user's browser to accept an attacker-controlled code. Attaching an identifier to the request and expecting it back prevents such an attack, since the attacker can't guess the token your app stored to identify the flow.

Proof Key for Code Exchange

We also need a second token, the PKCE. This will be a key to identify your app's session, and really guarantee the server that you didn't get supplanted midway. But we won't send the key yet, we will hash it, and we'll send the hashed result. This is called the "challenge." Basically like sending a note in a closed envelope and waiting until the very end to reveal what's inside, to prove that it's you who wrote the envelope and weren't taken over by your evil twin.

Store the OAuth state

Just before redirecting the user to the server's login page, store a "partial" OAuth state with the information gathered so far, so you can refer back to it when the server gets back to your app. Store it under the token generated for CSRF protection, as an "id" of sorts.

Token exchange

After the user authorizes the server for login, the server redirects to your app's callback with an authorization code. Now, your app sends a POST request to the token endpoint in your server, with the code the server sent you (like, "hey this is for this user's request") and the PKCE key without hashing (enacting the closed envelope trick.) The server confirms that hashing the key matches the challenge received earlier (the closed envelope really contained what you say it contains), and sends you back 2 tokens: the access token (for accessing your data, as the name suggests), which is short-lived; and a refresh token (for refreshing the access token whenever it dies) which is long-lived. These lifetimes are important: if your access token gets leaked, it will only be a security concern until it dies, and that's why it's short-lived. Only your app, owning the refresh token, can keep generating new access tokens. And since the refresh token isn't used nearly as often as the access token (which is required for any action that needs you to confirm your identity), it's harder to intercept.

Generate a token (again)

When the app receives a callback request and verifies that it matches one of the partial OAuth states you stored in memory, you need to create a new one. This is because the first token has been exposed on the internet through both the login request and callback calls, and someone doing man-in-the-middle could use the original token to supplant your user's identity when using your app. So instead, when you receive a callback request and you verify it as valid, you throw away the previous token, make a new one, and store the final OAuth state (with the access and refresh tokens) under this new identification token, which is secret and only known to your app. This can be "safely" stored in a cookie ("safely" insofar that you can somewhat claim cookie safety is outside your responsibility.)

What I'm describing is the PDS implementation of OAuth, but as far as I understand, everything described up to this point should apply in a similar fashion to any OAuth implementation. However, Bluesky implements DPoP on top of all this:

What is DPoP

DPoP stands for "Demonstration of Proof-of-Possession." In plain OAuth, whoever holds the access token can use it, so if it gets intercepted before it expires, the interceptor has free rein until it dies. What DPoP does is bind the token to a cryptographic keypair, so just stealing the token isn't enough anymore. You'd also need the private key, which never leaves your app.

Generate a keypair

Before the OAuth flow starts, your app generates an asymmetric keypair. The private key stays in your app; the public key tags along with each DPoP proof (more on those later) so the server can verify the signatures.

Sign every request

For every request that hits an auth-protected endpoint, your app generates a small signed token, called a DPoP proof. It says, essentially, "this request, with this method and this URL, made at this moment, is signed by the holder of this public key." The server verifies the signature and trusts that the request was made by the legitimate owner of the keypair. Note that the proof needs to be regenerated for each request, since it's tied to the request's specific method and URL.

Token binding

The first place DPoP enters the picture is the token exchange. Your app sends a DPoP proof alongside the request that asks for the access token. The server stores the public key from the proof and binds the access token to it. From that point on, the access token by itself is useless: any request using it must also carry a fresh DPoP proof signed by the matching private key (that the server can verify with the stored public key).

There's also a small change in the Authorization header: instead of the usual Bearer <token> scheme, you switch to DPoP <token>, telling the server there's a proof to verify too.

There are also nonces

Bluesky's PDSs ask for one more thing: nonces in the DPoP proofs. The first request you send doesn't have a nonce; the server responds with an error and a DPoP-Nonce header containing the value you should use. You retry, this time including the nonce in your DPoP proof. From there on, every response includes a fresh DPoP-Nonce, and you're expected to use the most recent one in your next request.

This adds another layer of replay protection: even if someone captured a full request along with its DPoP proof, they couldn't replay it later, because the nonce would already have rotated.