An Atmosphere Conf 2025 Lightning Talk
I recently embarked on the task of adding Bluesky Oauth Login to Coral by Vox Media (a popular comments tool) as an open source contribution.
In this talk I will give a brief overview of how that went, and what you should know before attempting to create your own atprotocol oauth clients.
- Tales of Adding oAuth "Login with Bluesky" to an OS Comments Tool
- An Atmosphere Conf 2025 Lightning Talk
Coral by Vox Media was originally founded by The New York Times, Washington Post and Mozilla Foundation to bring journalists and the communities they serve closer together. Today this open source app powers the comments sections of sites all over the world.
Having previously worked as an SRE on Coral, I recently embarked on the task of adding Bluesky Oauth Login to Coral as an open source contribution.
Coral is a multi-tenant node app depending on Mongo & redis databases. Existing auth strategies include: em/pwd, SSO, OIDC, as well as oAuth2 clients for FaceBook and Google.
Because of this quote taken directly from https://atproto.com/specs/oauth
OAuth 2.0 is traditionally an authorization (authz) system, not an authentication (authn) system, meaning that it is not always a solution for pure account authentication use cases, such as "Signup/Login with XYZ" identity integrations. OpenID Connect (OIDC), which builds on top of OAuth 2.0, is usually the recommended standard for identity authentication. Unfortunately, the current version of OIDC does not enable authentication of atproto identities in a secure and generic way. The atproto profile of OAuth includes a (mandatory) mechanism for account authentication during the authorization flow and can be used for atproto identity authentication use cases.
Initially I assumed that adding Bluesky would be as simple as reusing Coral's existing oauth2
client. I thought that I would copy either the FB or Google authenticator class and just make a new Bluesky one.
TLDR - that was an incorrect assumption.
After spending the better part of a week building out login-button-containers
, and adding the settings interface and updating models to allow users to enable "Login with Bluesky" in the multi-tenant Admin/Config/
form routes, I start to dig into the oauth clients, and realize that...
"none of this is how this is going to work"
As I start cloning & copy pasta-ing Coral's FB Authenticator Class, (an extension of an internal oauth2
abstract class) I realize that I'm not going to be able to use Coral's existing built in oauth2
client with Atproto's oauth-client-node
Already, this is way past the quick and easy copy/pasta job that I initially embarked on. I had thought that creating all of the login-button-containers
etc was going to be the hard part, and that the Oauth part would be pretty simple.
- Because an atproto profile can live anywhere on the internet, not just at
bsky.social
theredirect-url
(where you send the user) isn't static, it can be any pds host - There is no central authority to issue client secrets, so you create and issue your own client ID & secret keys at a
/client-metadata.json
route.
π΄ It was at this point that I decided it was time to externally validate my idea. I needed to even confirm if anyone else in the world besides myself even wanted this integration to exist.
- I opened an issue on Coral to make sure that they actually wanted this contribution β
- I submitted this very lightning talk to the CFP, assuming that if it was selected that meant that this was something the atproto community saw value in as well. β
I needed to get a better understanding of how Atproto's oauth-client-node
flow was going to actually work. So I:
- Cloned statusphere-example-app
- Used it as a template to make my own even simpler example app that ONLY does oauth
Once I could use my own Bluesky profile to authorize my sample app locally, I was ready to go back to Coral.
π¬ Meanwhile, Coral's developers had published some new integration tests around auth strategies, and it was suggested that I branch off of those tests to ensure that my changes wouldn't break anything.
So I cherry picked my changes to a new branch. π
- Can not resolve handle
- 401 not authorized
Some errors encountered were:
- not correctly passing
{ signal, ...options }
toclient.authorize()
resulted inCan not resolve handle
sincescope: "atproto transition:generic"
was missing - not correctly connecting the
StateStore
to theclient
resulted in a401 not authorized
due to theclient
failing to match thestate
on the callback
- Step 1, the atproto oauth
client
is instantiated &&/client-metadata.json
exists - Get the user's handle ie
"immber.bsky.social"
- Try to pass the handle to
client.authorize()
- The
client
setsstate
in theStateStore
- The
client.authorize()
returns theredirect_url
ie: https://bsky.social/oauth/authorize?client_id=http%3A%2F%2Flocalhost%3Fredirect_uri%3Dhttp%253A%252F%252F127.0.0.1%253A8080%252Foauth%252Fcallback%26scope%3Datproto%2520transition%253Ageneric&request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Areq-eb89d03dc33c4de8b86d12a60778fb00
- The
- User completes authorization and is sent back to the
/callback
route withURLSearchParams: { iss, state, code )
- Pass the URLSearchParams to
client.callback()
- The
client
getsstate
from theStateStore
to compare withstate
that came back in params - If matched,
client
deletesstate
and setssession
- The
session
obj is returned byclient.callback()
, and the user is authorized
- The
- Use the
session
to instantiate anAgent
- Use the
Agent
to make authorized API calls,Agent
will call getsession
on theclient
as needed - Handle
session
termination and refresh
At this point, I am working with Coral to complete the integration, and take it through their QA process for release. I was hoping to have it deployed by the time of this talk, but that is still a work in progress.
- β Would I have attempted this if I'd known how long it would take?
- Definitely no
- β Did I learn a lot?
- Tons
- Ultimately, it was worth it
- β Would I do it again?
- Probably yes
- You're not just adding "Bluesky" oauth, it's any atproto pds host, so there isn't a single redirect URL to send the user to
- The process is different from other socials, instead of registering your client with a central authority like FB or Google, have to issue your own ClientID and secrets
- I used the
oauth-client-node
npm package which handles all of the "token hockey" for you. - If you've written your own
oauth2
clients from scratch in the past you don't have to do that this time, but also, you probably can't easily reuse your existing ones
- I struggled a bit with the localhost overrides for dev testing, this is called out in the docs so pay extra attention and don't forget to encode your uri
- Use
@atproto/syntax
to validate handles before passing toclient.authorize()
- Use the API Agent
@atproto/api
togetProfile()
, and interact with the atproto authenticated user once you have a session