Properties
category: reference tags: [spike, atproto, oauth, auth] last_updated: 2026-03-15 confidence: high
VS-1: ATProto OAuth Spike
Status: COMPLETE — ready for manual deployment and testing
What was built
A throwaway spike adapting the Bluesky cookbook Flask OAuth demo (CC-0) for robot.wtf identity-only authentication.
Branch: feat/vs-1-atproto-spike in the robot.wtf repo
Location: spike/atproto-oauth/
Key adaptations from cookbook demo
- Scope changed to
"atproto"— identity-only, no PDS write access. The ATProto spec explicitly supports this for "Login with atproto" use cases. - Fixed client_id at
https://robot.wtf/auth/client-metadata.json(cookbook dynamically computes it from request host) - All routes under
/auth/prefix to match Caddy routing - JWK loaded from file (
/srv/data/client_jwk.json) instead of environment variable - Inline JWKS in client metadata instead of separate
jwks_uriendpoint (simpler for spike) - Profile page displays DID, handle, and display name (fetched from public Bluesky API)
- Removed Bluesky posting feature,
atproto_util.py,bsky_util.py - Removed
regexdependency — using stdlibre(no Unicode handle validation needed for spike)
Files
| File | Lines | Source |
|---|---|---|
app.py |
~280 | Heavily adapted from cookbook app.py |
atproto_oauth.py |
~230 | Cookbook, removed pds_authed_req |
atproto_identity.py |
~100 | Cookbook, unchanged |
atproto_security.py |
~40 | Cookbook, unchanged |
schema.sql |
~20 | Cookbook, unchanged |
templates/ |
4 files | New (simpler than cookbook) |
requirements.txt |
6 deps | Subset of cookbook |
Smoke test results
- Flask app starts and initializes SQLite database
/auth/client-metadata.jsonserves correct JSON with all required ATProto OAuth fields/auth/loginrenders login form/auth/redirects to/auth/loginwhen not authenticated- Public key in JWKS contains no private material (
dfield absent)
Dependencies
Flask>=3.0 authlib>=1.3 dnspython>=2.6 requests>=2.32 requests-hardened>=1.0.0b3 cryptography>=41.0
Deployment steps
- Copy
spike/atproto-oauth/to VPS at/srv/app/atproto-spike/ - Install deps in
/srv/app/venv - Set
FLASK_SECRET_KEYenv var - Run
flask --app app run --host 127.0.0.1 --port 8003 - Test at
https://robot.wtf/auth/login
What to watch for during manual testing
- DPoP nonce errors: The spike preserves the cookbook's retry-on-nonce-error pattern. If the first PAR request fails with
use_dpop_nonce, it retries with the server-provided nonce. Watch logs for"retrying with new auth server DPoP nonce". - Client metadata fetch: The PDS fetches
https://robot.wtf/auth/client-metadata.jsonduring PAR. If Caddy isn't routing correctly or the response is malformed, PAR will fail with a 400. - Scope verification: The callback asserts
tokens["scope"] == "atproto". If the AS returns a different scope string, the assertion will fail. This is the most likely breakage point. - Session cookie: Requires
FLASK_SECRET_KEY. Without it, Flask will error on any session operation.
Findings for V3 production implementation
- The cookbook code is well-structured and directly usable. The
atproto_oauth.pyandatproto_identity.pymodules can transfer to production with minimal changes. - Identity-only scope (
"atproto") is confirmed supported by the spec. Thesubfield in the token response contains the DID. requests-hardenedupgraded to 1.2.0 (stable, no longer beta). SSRF mitigations work.- The
authlib.josemodule handles all JWT/JWK operations (nojoserfcneeded). - For production: replace SQLite session store with the platform's user/session tables, mint platform JWT on successful auth, add error handling beyond bare
assertstatements.