Beyond the Statusphere: Part 2, ATProto OAuth, the TLDR

Welcome to my Beyond the Statushphere series! In this series, we go beyond the content taught to you in the Statusphere tutorial. If you have not gone through that yet, it is highly recommended to do so first. I'd also recommend checking out Beyond the Statusphere: Part 1, Resolving the User's PDS first as well so you are familiar with the flow of finding the user's PDS. Most OAuth libraries handle that for you, but knowing the flow will help in your troubleshooting and understanding of how to do OAuth to support all users on the atmosphere.

I'm not going to lie to you, OAuth is pretty tough. It's nothing outside of your wheelhouse though, even if you think it may be. And once you get a grasp on how to implement it in your projects, you will wonder why you ever thought it was giving you so much trouble. But it takes a bit to get there. We will not be getting into the low level implementation details, but more so what I call a "working knowledge" so you can start using OAuth in your projects.

Most examples use the TypeScript libraries from Bluesky for code examples, but the core concepts are the same across all ATProto OAuth libraries

What is OAuth?

OAuth is a standard for web authorization that allows you to authenticate with one application using your credentials to another. There's a good chance you've used it before with GitHub, Google, Microsoft, etc. In ATProto, you are using your account on your PDS to authenticate to other ATProto applications. Like anisota.net , leaflet.pub , stream.place , or Bluesky . You can use app passwords, but OAuth is more secure because apps can request access to specific parts of your account (scopes), and you can remove their access. OAuth also provides a better user experience, which makes it the preferred way for users to log in to ATProto apps.

Key components of ATProto's OAuth

These are the key components and ideas of ATProto's OAuth and learning these will help you in setting up OAuth on your application

oauth-client-metadata.json is a JSON file that describes your OAuth implementation, and the PDS has to make an HTTP request to get it to understand more about your application. Can see one here

client_id is just the URL to the oauth-client-metadata.json

You have scopes that describe which types of actions and records your application can have control over on the user's repo. These are just a list of permissions you are giving the ATProto application. They are described in the oauth-client-metadata.json under scope

With OAuth you create an authorized URL to the user's PDS that redirects them to a secure web page hosted by the PDS. This is where the user signs in. Your ATProto app never sees their password, and all authentication happens on the PDS. This is slightly different if the user's account is still hosted on a Bluesky PDS. The authentication actually happens on the entryway .

If a successful login, the PDS redirects back to the application's callback URL set in the client and one that is in the oauth-client-metadata.json under redirect_uris as a GET request with a code that the app then exchanges with the PDS for access and refresh tokens. Should be noted that a redirect can also happen with an error message from the PDS/OAuth provider.

You don't have to have a full backend for OAuth, you can just do it all client side. But if you have OAuth on the backend and use JWKS you can get longer session lifetimes.

There's a good chance the language you want to use OAuth with already has a solution with examples

You can implement OAuth in your application; I got faith in you

Different OAuth Clients

There are technically 2 different ATProto OAuth clients, public and confidential clients. I categorize them into 3, with the added one being a public client that works locally for development and allows you not to have to expose your application to the internet or set up a domain to use, making it much easier during development.

Localhost Public Client

For the localhost client you do not have to host the oauth-client-metadata.json for development. You can configure it all by the client_id (which is a URL) that you pass to the OAuth client. You also HAVE TO host/access your application via 127.0.0.1 or [::1] for ipv6. So instead of accessing your project from http://localhost:5173 you would use http://127.0.0.1:5173 in your web browser's address bar

Must start with http://localhost

You can then set a query parameter of redirect_uri and set it to your callback endpoint. This can be the ip address and port number like redirect_uri=http://127.0.0.1:3000/oauth/callback. You also have to use a loopback address like 127.0.0.1 here and not localhost

You can also set the scopes via a scope parameter with spaces like atproto transition:generic

The full url would look like this http://localhost?redirect_uri=http://127.0.0.1:5173/callback&scope=atproto transition:generic

A minimal example using @atproto/oauth-client-browser looks a bit like this while using the atprotoLoopbackClientMetadata helper

import {atprotoLoopbackClientMetadata, BrowserOAuthClient} from '@atproto/oauth-client-browser'

const clientId = `http://localhost?redirect_uri=${encodeURIComponent('http://127.0.0.1:5173/callback')}&scope=${encodeURIComponent('atproto transition:generic')}`
const client = new BrowserOAuthClient({
handleResolver: 'https://bsky.social',
clientMetadata: atprotoLoopbackClientMetadata(clientId)
})
await client.init()
//Auto redirects after if successful
await client.signIn('baileytownsend.dev')

Public Client

Public clients are usually a bit easier to setup since they do not use a client signing key. These are usually used if an application is client side only with no backend. Something like 2048.blue . Since they do not have a signing key, refresh tokens have a lifetime of 2 weeks.

Have a publicly accessible URL and domain. Which you will most likely have in production. This is needed since the PDS calls your application's client metadata endpoint to get information about it.

You must host a client metadata JSON file. It is recommended to host it at /oauth-client-metadata.json like https://demo.atpoke.xyz/oauth-client-metadata.json since it will make the PDS login screen show only the domain name instead of that full URL.

In the client metadata you have to set the following

client_id: This is the URL you are hosting the client metadata at. Like https://demo.atpoke.xyz/oauth-client-metadata.json

scope: your scopes, like atproto transition:generic

redirect_uris: an array of URLs that may be redirected to after a successful login. Also called your callback endpoints. When starting the login for OAuth you pass over which one you want to use in the authorization URL.

grant_types: array of strings, usually always ['authorization_code', 'refresh_token']. refresh_token is optional, but you usually want that

response_types: array of strings usually just set to ['code']

dpop_bound_access_tokens: always set to true

A minimal oauth-client-metadata.json would look a bit like this

{
"redirect_uris": [
"https://demo.atpoke.xyz/oauth/callback"
],
"response_types": [
"code"
],
"grant_types": [
"authorization_code",
"refresh_token"
],
"scope": "atproto transition:generic",
"token_endpoint_auth_method": "none",
"application_type": "web",
"subject_type": "public",
"client_id": "https://demo.atpoke.xyz/oauth-client-metadata.json",
"dpop_bound_access_tokens": true
}

A minimal example using @atproto/oauth-client-browser looks a bit like this, along with hosting the oauth-client-metadata.json somewhere accessible through the internet and with its URL set in the client metadata under client_id

import {BrowserOAuthClient} from '@atproto/oauth-client-browser'

const client = await new BrowserOAuthClient({
handleResolver: 'https://bsky.social',
clientMetadata: {
redirect_uris: [
"https://demo.atpoke.xyz"
],
response_types: [
"code"
],
grant_types: [
"authorization_code",
"refresh_token"
],
scope: "atproto transition:generic",
token_endpoint_auth_method: "none",
application_type: "web",
subject_type: "public",
client_id: "https://demo.atpoke.xyz/oauth-client-metadata.json",
dpop_bound_access_tokens: true
}
})
await client.init()
//Auto redirects after if successful
await client.signIn('baileytownsend.dev')

You can also cut corners a bit by not needing to set the client metadata both in the client setup and in a separate JSON file by passing in the clientId as the full URL to your oauth-client-metadata.json like so. The OAuth client will then load it in at runtime.

import {BrowserOAuthClient} from '@atproto/oauth-client-browser'

const client = await BrowserOAuthClient.load({
handleResolver: 'https://bsky.social',
clientId: 'https://demo.atpoke.xyz/oauth-client-metadata.json'
})

If you would like to see a working example of a full client-side OAuth project, I set up a simple project using vite and vanilla JS that you can find at baileytownsend.dev/atp-oauth-playground

Confidential Client

Confidential clients are a bit more secure since they are JWTs signed with a secret key. How this works is a bit out of scope for this article, but the important bits to know are your application has private signing keys kept secret on the server, and an endpoint that shares the public keys. You can revoke these keys to invalidate all the sessions for your application. Because of these extra security measures, the lifetime for these is indefinite as long as you keep refreshing the tokens, which the refresh tokens have a lifetime of 180 days.

Since it's a private secret key, the Confidential Client has to be a backend-only implementation. It is usually recommended that you implement this by using secure cookie sessions in the framework and language of your choice. Securely save the users did that is logged in on the session, then you can resume OAuth sessions using the ATProto OAuth Client to make requests.

Besides the oauth-client-metadata.json you will also need to host an endpoint that returns the JWKS. The URL is set in the client metadata, but /.well-known/jwks.json is a common location. The setup for creating these keys is different for each library, but they usually have an example.

The oauth-client-metadata.json has some extra fields required since it has the signing keys

All the same fields as before for the Public client

token_endpoint_auth_method: is set to private_key_jwt

jwks_uri: is set to the full URL hosting your public JWK (what we talked about in the second bullet point). Like https://demo.atpoke.xyz/.well-known/jwks.json

token_endpoint_auth_signing_alg: The signing key algorithm. Depends on what you are using for the signing key.

.well-known/jwks.json example

{
"keys": [
{
"kty": "EC",
"kid": "1765346061386",
"key_ops": [
"verify",
"encrypt",
"wrapKey"
],
"crv": "P-256",
"x": "vNo3iiUIpd219ipYi03oRdlhYhIS6jFyEGqLJ8SN3Y0",
"y": "jKHOVkjMrrHsUqszAKLfvZtL_yBtIA_fR6aP51cnlg4"
}
]
}

full oauth-client-metadata.json example

{
"redirect_uris": [
"https://demo.atpoke.xyz/oauth/callback"
],
"response_types": [
"code"
],
"grant_types": [
"authorization_code",
"refresh_token"
],
"scope": "atproto transition:generic",
"token_endpoint_auth_method": "private_key_jwt",
"token_endpoint_auth_signing_alg": "ES256",
"jwks_uri": "https://demo.atpoke.xyz/.well-known/jwks.json",
"application_type": "web",
"subject_type": "public",
"authorization_signed_response_alg": "RS256",
"client_id": "https://demo.atpoke.xyz/oauth-client-metadata.json",
"client_uri": "https://demo.atpoke.xyz",
"dpop_bound_access_tokens": true
}

You can use Bluesky's @atproto/oauth-client-node TypeScript library for this. If you want to see a demo/template for this, you can check out my Svelte template at baileytownsend.dev/atproto-sveltekit-template .

Scopes

Scopes are one of the big benefits of using OAuth over app passwords in ATProto. With scopes, an application can request to have access to only certain parts of the user's account and not the whole. This lets users trust your application much more since it will only have limited access to your ATProto account. When OAuth launched, there was a very limited number of scopes developers had access to, and it was all or nothing with the scopes atproto transition:generic this gave you full access to the users, repo meaning you could create, edit, or delete any record. This would also show a pretty scary screen like this for the user on login to your application.

Vs using well defined scopes. Like Piper that only asks for access to atproto repo:fm.teal.alpha.feed.play repo:fm.teal.alpha.actor.status. This means Piper cannot add, edit, or delete any other record unless it is in the fm.teal.alpha.feed.play or fm.teal.alpha.actor.status collection. Resulting in a much less scary login screen.

For full scopes, you can find the full breakdown here . Scopes are also available now in the PDS and you can start using them. Bluesky is still hammering out the last details for Permission Sets , as well as writing documentation and examples. But you are free to use them, and I recommend using them for any new applications you are building. I have been for about a month with no issue.

Scopes are defined by a space in the OAuth client metadata all inside of the scope property. Some clients also follow this standard or have an array for them on setting up the client.

Types of scopes

atproto: required for every OAuth client to have set. It's the base scope for checking for authentication. Gives no other access

transition:generic: Access to everything like an app password. This is what most OAuth ATProto applications currently use and what shows the scary login page. Helpful for development

repo: Access to the user's records. repo:fm.teal.alpha.actor.status would give full read/write/delete access to every record in that collection

rpc: Gives permission to make authenticated requests to other services (XRPC endpoints), like AppViews.

blob: Permissions for blob uploads. Like pictures and videos. You can gatekeep things like file type.

identity: Permissions over your identity and requesting changes to your did doc. Like handle changes, and migrations to another PDS

account: Access to things about your account. Like reading your email, or allowing an import of the repo like during a migration.

Examples

repo

If you want full access to create, delete, and edit a collection of lexicons in a repo, you would do repo:xyz.statusphere.status.

If you'd like to just specify some CRUD actions you can do repo:xyz.statusphere.status?action=create if you need multiple can append another action repo:xyz.statusphere.status?action=create&action=delete

If you wanted access to multiple collections, you would have to specify each one with a space between

repo:xyz.statusphere.status repo:xyz.statusphere.example

Or can do it as one line like URL query strings

repo?collection=xyz.statusphere.status&collection=xyz.statusphere.example

Note that wildcards do not work for collections. You cannot do repo:xyz.statusphere.* to have access to everything under that NSID. Permission sets are the solution for handling applications that need access to a lot of different repos. Can keep scrolling down to see those examples

rpc

rpc will give you permission to make authenticated atproto-proxy requests for the user to different applications via the PDS. This is used for things like AppViews. Like Bluesky, or making moderation reports to ozone. This also includes requests to the com.atproto.server.getServiceAuth endpoint.

The permissions you request for these are divided up between two fields.

lxm This is the lexicon method, the API endpoint of the XRPC service you are calling. Like com.pdsmoover.com.backup.signUp which gives permission for the application to proxy a request to the endpoint /xrpc/com.pdsmoover.com.backup.signUp via the PDS. You can set this to a wildcard to allow access to any endpoint

aud this is the allowed did:web you can call. did:web:pdsmoover.com#repo_backup means it is allowed to proxy the request to the URL found inside pdsmoover.com's did.json under the #repo_backup service. Wildcards may allow any aud. You can set this to a wild card to allow any aud. BUT you cannot have a wild card in both the aud and lxm

For permission to make a webcall to the XRPC endpoint with a did:web setup properly to https://pdsmoover.com/xrpc/com.pdsmoover.com.backup.signUp the scope would be

rpc?lxm=com.pdsmoover.com.backup.signUp&aud=did:web:pdsmoover.com#repo_backup

Calling any XRPC endpoint would be

rpc?lxm=*&aud=did:web:pdsmoover.com#repo_backup

And allowing any aud for an lxm

rpc?lxm=com.pdsmoover.com.backup.signUp&aud=*

blob

The blob scope gives the application the ability to upload blobs on behalf of the user. blob:mimetype or blob?accept=mimetype

Allows all

blob:*/*

Allows any video or image format

blob?accept=image/*&accept=video/*

Allow html

blobs:text/html

identity

The identity scope gives control over the user's did doc. This is usually only for did:plc since the user manages a did:web differently

identity:handle would let the user have permission to change the users handle

identity:* gives full control over the did doc, which is helpful for signing updates during a migration or adding a rotation key. This does come with a big warning for the user though, as it should

account

account gives access to things like accessing the email address, turning on 2fa, and importing a repo. account:email gives the application access to your email address. account:repo?action=manage gives access to upload a repo for the user via the com.atproto.repo.importRepo endpoint. This allows the application to read, write, edit or delete any record doing this, though, comes with a proper warning.

What if I need, like, a lot of scopes for my application?

If your application needs permissions to a lot of things, it gets harder to manged it all in a single string in the client metadata. Permission sets solve this, giving you an organized way to request permissions with a single scope. Permission sets let you set the scopes within a lexicon schema you publish. So you don't end up with scopes that are a mile long. Some more official information on them

Introduction to them

More info

Draft of Bluesky's permission sets for the largest application on the atmosphere

I have not actually used these yet in production, and they are still being finalized. So you may hold off on moving everything to them right now. But if you wanted to get a start on trying them out, you can.

For this section, it would be handy to know how to create Lexicons and how to publish schema lexicons and validate them. goat makes this easy with the command goat lex publish ./lexicons command. You can read about creating them from the Stautsphere tutorial and @nickthesick.com has a great writeup on how the publishing process works.

For a simple example, I have a bunch of lexicons at dev.baileytownsend.demo and I do not want to have to write a super long scope to handle them all. Well, you can create a new lexicon schema type called permission-set and publish it along with your other lexicon schemas to define them in a single JSON file with descriptions. We will just be using a single lexicon and demoing it for the repo permission, but this works for multiple lexicons and scope permission types like identity, blob, etc.

Our lexicon file for dev.baileytownsend.demo.example looks like this

{
"defs": {
"main": {
"description": "An example record for showing permissions ets",
"key": "tid",
"record": {
"properties": {
"createdAt": {
"format": "datetime",
"type": "string"
},
"name": {
"description": "A name of something",
"maxGraphemes": 64,
"maxLength": 640,
"minLength": 1,
"type": "string"
}
},
"required": [
"name",
"createdAt"
],
"type": "object"
},
"type": "record"
}
},
"id": "dev.baileytownsend.demo.example",
"lexicon": 1
}

and our permission-set lexicon schema file dev.baileytownsend.demo.authBasicFeatures holds the permissions for all of our OAuth scopes for our application. Note the prefix auth, this is a naming convention. You may even have one named authOnlyForXFeature or something similar.

{
"id": "dev.baileytownsend.demo.authBasicFeatures",
"lexicon": 1,
"defs": {
"main": {
"title": "Basic App Functionality",
"description": "An example simple permission set",
"type": "permission-set",
"permissions": [
{
"type": "permission",
"resource": "repo",
"collection": [
"dev.baileytownsend.demo.example"
]
}
]
}
}
}

You can see the permissions are set in defs.main.permissions this is an array that takes multiple permissions. Shown there is for the repo scope. This will give me full write access to the collections listed in collection. If you wanted to limit it to just create you can do this

{
"type": "permission",
"resource": "repo",
"collection": [
"dev.baileytownsend.demo.example"
],
"actions: ["create"]
}

For more info on the permission-set schema can check this out .

This then simplifies the scopes you have in your oauth-client-metadata.json and client to just atproto include:dev.baileytownsend.demo.authBasicFeatures. The PDS then resolves the permission-set lexicon and gets the full lexicons needed from it. The OAuth login screen would then look like this for the user

I think there is a slight bug if you are using a lexicon schema that is hosted on your PDS and the repo owner of the schema is also hosted on your PDS that is trying to resolve that schema. For example dev.baileytownsend.demo.authBasicFeatures did not resolve for me on my PDS trying to login since my account is hosted there with that repo and internally it does not resolve. But if I use an account on another PDS, it resolves fine. Something to keep a note of, and I'll dig into it some more when I have the time.

Production example

@evan.jarrett.net was kind enough to share that he is using these in production for his very cool project @atcr.io which is a container registry built on ATProto.

OAuth Client metadata

Permission Sets

Odds & ends

Handle resolving in @atproto/oauth-client-browser

You'll notice in the examples for this all the clients had this bit of code

const client = await new BrowserOAuthClient({
handleResolver: 'https://bsky.social'
})

This is so the client can resolve the user's PDS from their handle like we talked about in Beyond the Statusphere: Part 1, Resolving the User's PDS . You'll notice this is pointing to the entryway at https://bsky.social to resolve the user's PDS giving your ATProto app a reliance on Bluesky. If you'd rather do the DNS/HTTP look up you can use the AtprotoDohHandleResolver with a DNS over http service like below

import {
AtprotoDohHandleResolver,
BrowserOAuthClient
} from '@atproto/oauth-client-browser'

const client = await BrowserOAuthClient.load({
handleResolver: new AtprotoDohHandleResolver({
dohEndpoint: 'https://cloudflare-dns.com/dns-query'
}),
})

Client metadata life hack for @atproto/oauth-client-browser

So for clients, you usually need to give it the client metadata, and host and endpoint with the same information for the PDS to call directly. Kind of annoying depending on how you have your application structured. I recently found in @atproto/oauth-client-browser that you can set the clientId to the full URL for the metadata and not have to have it in both places. Then, it just dynamically loads it in at runtime. Works for localhost dev clientIds as well.

const client = await BrowserOAuthClient.load({
handleResolver: resolver,
clientId: 'https://dev.modelo.social/oauth-client-metadata.json',
//devClientId,
// clientId: `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent('atproto transition:generic')}`
})

Client Metadata caching

When using the oauth-client-metedata.json the PDS will cache it. So if you change your scopes or redirect_uris and do not see it reflected on the OAuth login screen, this is why. I have found this is usually 10-20mins. The localhost public client does not do this, so it's nice for development. Permission sets are also cached. Can read more on that here

Application Branding

There are a few things you can do to help with branding on the login screen by setting these on your client metadata

If you host your client metadata/ client_id to /oauth-client-metadata.json then it will display only the application's URL not the entire URL to the file

Setting tos_uri and policy_uri will show your terms of service and policy links on the OAuth login page

Setting client_name and logo_uri gives a friendly name and picture that shows up if the user vists their account page on the PDS at https://mypds.com/account

Got any good client metadata examples?

In no order

smokesignal.events

at://2048

stream.place

leaflet

ATProto OAuth libraries

The great thing about ATProto OAuth is there is a very good chance you may not even need to roll your own client, and there is already just one there for you with examples. Here's a list of some I know of and have used.

@atproto/oauth-client-browser - TypeScript

Public, all client side in the web browser

@atproto/oauth-client-node - TypeScript

Public/Confidential Client. Server side

jacquard - Rust

Public/Confidential client

example of a public client

atproto-oauth - Rust

Public/Confidential client

crate for axum

@atcute/oauth-browser-client - TypeScript

Public, all client side in the web browser

@atcute/oauth-node-client - TypeScript

Public/Confidential client. Server side

indigo's oauth client - Go

Public/Confidential Client

example

From scratch implementation if you want to roll your own - Python

Public/Confidential client

AIP

ATProto identity provider. Handles OAuth for you and gives you an interface for making secure calls

streamplace/oatproxy

Also handles OAuth for you and can be used with the official TS packages to make calls to it, then it makes authenticated requests to the PDS for you

The End

Okay, maybe that was too long; didn't read. If you made it this far, thanks for reading! Hopefully now you have a good idea of how ATProto OAuth works and can implement it in your projects (and not be forever terrified of it and will stick to app passwords)