AT Protocol OAuth 2.1 + DPoP library and CLI: handle/DID/PDS resolution, PAR, PKCE, refresh-token flow, and DPoP-bound resource calls.
Project description
atproto-oauth-py
AT Protocol OAuth 2.1 + DPoP library and CLI for Python. Handles handle/DID/PDS resolution, PAR (Pushed Authorization Requests), PKCE, refresh-token rotation, and DPoP-bound HTTP calls.
Status
Works end-to-end against the OAuth flow: init resolves a handle through to a
PDS-issued authorization URL, complete exchanges an authorization code for a
refresh token + DPoP private key. The library also exposes runtime DPoP helpers
for making authenticated calls.
There is one important caveat for the BlueSky use case (see Limitations
below): BlueSky's AppView at bsky.social rejects OAuth tokens minted by
third-party PDSes with "OAuth tokens are meant for PDS access only". OAuth is
fine for talking to your own PDS; cross-PDS AppView calls require service-auth
or app-password authentication instead.
Install
pip install atproto-oauth-py
CLI: one-time auth
The CLI is two-phase. Between the phases, you open a browser, authorize, and paste back the redirect URL.
You also need a publicly-hosted client metadata JSON document and a
callback page at the redirect URI. Templates are included under
oauth-templates/ — copy them to your hosting and edit the URLs to match your
domain.
# Phase 1: build the authorization URL.
export ATPROTO_OAUTH_CLIENT_ID=https://example.com/oauth/client-metadata.json
export ATPROTO_OAUTH_REDIRECT_URI=https://example.com/oauth/callback/
atproto-oauth init alice.bsky.social
This prints an authorization URL. Open it in a browser, sign in to your PDS, authorize the request. You'll land on the callback page, which displays the full URL it received. Copy that URL.
# Phase 2: exchange the auth code for tokens.
atproto-oauth complete 'https://example.com/oauth/callback/?code=...&state=...&iss=...'
This prints four credentials to add to your secrets store:
ATPROTO_OAUTH_REFRESH_TOKEN=...
ATPROTO_OAUTH_DPOP_PRIVATE_JWK={"kty":"EC","crv":"P-256",...}
ATPROTO_OAUTH_PDS_ISSUER=https://your-pds
ATPROTO_OAUTH_DID=did:plc:...
Phase 1 persists transient state to .atproto-oauth-state.json (gitignored);
phase 2 reads and consumes it.
Library: runtime DPoP-bound calls
Once you have the four credentials above, you can call your PDS:
import json, os
from atproto_oauth.dpop import dpop_post_form, dpop_get, jwk_to_key, public_jwk
from atproto_oauth.flow import discover_token_endpoint
private_jwk = json.loads(os.environ["ATPROTO_OAUTH_DPOP_PRIVATE_JWK"])
private_key = jwk_to_key(private_jwk)
pub_jwk = public_jwk(private_jwk)
# Refresh access token.
token_endpoint = discover_token_endpoint(os.environ["ATPROTO_OAUTH_PDS_ISSUER"])
tokens = dpop_post_form(
token_endpoint,
{
"grant_type": "refresh_token",
"refresh_token": os.environ["ATPROTO_OAUTH_REFRESH_TOKEN"],
"client_id": os.environ["ATPROTO_OAUTH_CLIENT_ID"],
},
private_key, pub_jwk,
)
access_token = tokens["access_token"]
# Watch for refresh_token rotation; if tokens["refresh_token"] != the one you
# sent, persist the new one before the next call.
# Now make a DPoP-bound call.
r = dpop_get(
f"{os.environ['ATPROTO_OAUTH_PDS_ISSUER']}/xrpc/com.atproto.repo.describeRepo",
access_token, private_key, pub_jwk,
params={"repo": os.environ["ATPROTO_OAUTH_DID"]},
)
print(r.json())
Hosting the client metadata
OAuth 2.1 for AT Protocol uses a publicly-hosted JSON document as the
client_id. Copy oauth-templates/client-metadata.json to your web host
(GitHub Pages, Netlify, S3, etc.), edit the URLs, and serve it at a stable URL
that becomes your ATPROTO_OAUTH_CLIENT_ID.
oauth-templates/callback/index.html is the page that receives the
authorization redirect — it just displays the URL it was loaded with so you
can copy it back to phase 2.
Both files have no server-side logic; static hosting is fine.
Limitations
- Tested against: bsky.social and eurosky.social PDSes. Other AT Protocol
PDSes that expose
/.well-known/oauth-protected-resourceand/.well-known/oauth-authorization-servershould work but haven't been verified. - AppView interop: BlueSky's AppView (
bsky.social'sapp.bsky.*endpoints) explicitly rejects OAuth-minted access tokens with"OAuth tokens are meant for PDS access only". This is a BlueSky policy, not a fix-able bug. OAuth tokens work for talking to your own PDS; for AppView calls you need service-auth (which only works for bsky.social-hosted accounts in practice) or an app password. - Single-user: there is no token store, no multi-account support, and no encrypted-at-rest persistence. The library returns the credentials and you decide where to put them (env vars, secret manager, etc.).
License
MIT. See LICENSE.
Provenance
Extracted from https://github.com/tenorune/tenorune.github.io's scripts/
directory, where it powered the OAuth scaffolding for the Stories of 47
archive's BlueSky save ingestion. The runtime ingestion lives in
bsky-saves.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file atproto_oauth_py-0.1.0.tar.gz.
File metadata
- Download URL: atproto_oauth_py-0.1.0.tar.gz
- Upload date:
- Size: 12.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9e5083a4fb47449119362dd2008d9e2a6e0218c613123dc570547a6d04a7c597
|
|
| MD5 |
1b49e5820254250c7fe27aa5371ab257
|
|
| BLAKE2b-256 |
67f8ac58077457918f291cf38b2413a1c18f2f1e53dc66f7aa9a39bd5aa6597c
|
Provenance
The following attestation bundles were made for atproto_oauth_py-0.1.0.tar.gz:
Publisher:
release.yml on tenorune/atproto-oauth-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
atproto_oauth_py-0.1.0.tar.gz -
Subject digest:
9e5083a4fb47449119362dd2008d9e2a6e0218c613123dc570547a6d04a7c597 - Sigstore transparency entry: 1413054422
- Sigstore integration time:
-
Permalink:
tenorune/atproto-oauth-py@dcc13e0e1767a6accd2a0ee78a9252821253ebf0 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/tenorune
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@dcc13e0e1767a6accd2a0ee78a9252821253ebf0 -
Trigger Event:
push
-
Statement type:
File details
Details for the file atproto_oauth_py-0.1.0-py3-none-any.whl.
File metadata
- Download URL: atproto_oauth_py-0.1.0-py3-none-any.whl
- Upload date:
- Size: 12.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
24da4b758378ed958357dcbf97e2e5351c4082d44e86a76ed706eb2e1b504aaf
|
|
| MD5 |
71341f28c46ae413d4592f3e496138bd
|
|
| BLAKE2b-256 |
7e59a15627fe86599ddfc1a8f70c6833ce604955dde4c45a899a5e7c4d902086
|
Provenance
The following attestation bundles were made for atproto_oauth_py-0.1.0-py3-none-any.whl:
Publisher:
release.yml on tenorune/atproto-oauth-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
atproto_oauth_py-0.1.0-py3-none-any.whl -
Subject digest:
24da4b758378ed958357dcbf97e2e5351c4082d44e86a76ed706eb2e1b504aaf - Sigstore transparency entry: 1413054524
- Sigstore integration time:
-
Permalink:
tenorune/atproto-oauth-py@dcc13e0e1767a6accd2a0ee78a9252821253ebf0 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/tenorune
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@dcc13e0e1767a6accd2a0ee78a9252821253ebf0 -
Trigger Event:
push
-
Statement type: