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.socialtheredirect-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.jsonroute.
π΄ 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 handlesincescope: "atproto transition:generic"was missing - not correctly connecting the
StateStoreto theclientresulted in a401 not authorizeddue to theclientfailing to match thestateon the callback
- Step 1, the atproto oauth
clientis instantiated &&/client-metadata.jsonexists - Get the user's handle ie
"immber.bsky.social" - Try to pass the handle to
client.authorize()- The
clientsetsstatein theStateStore - The
client.authorize()returns theredirect_urlie: 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
/callbackroute withURLSearchParams: { iss, state, code ) - Pass the URLSearchParams to
client.callback()- The
clientgetsstatefrom theStateStoreto compare withstatethat came back in params - If matched,
clientdeletesstateand setssession - The
sessionobj is returned byclient.callback(), and the user is authorized
- The
- Use the
sessionto instantiate anAgent - Use the
Agentto make authorized API calls,Agentwill call getsessionon theclientas needed - Handle
sessiontermination 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-nodenpm package which handles all of the "token hockey" for you. - If you've written your own
oauth2clients 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/syntaxto validate handles before passing toclient.authorize() - Use the API Agent
@atproto/apitogetProfile(), and interact with the atproto authenticated user once you have a session






