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

  1. Scope changed to "atproto" — identity-only, no PDS write access. The ATProto spec explicitly supports this for "Login with atproto" use cases.
  2. Fixed client_id at https://robot.wtf/auth/client-metadata.json (cookbook dynamically computes it from request host)
  3. All routes under /auth/ prefix to match Caddy routing
  4. JWK loaded from file (/srv/data/client_jwk.json) instead of environment variable
  5. Inline JWKS in client metadata instead of separate jwks_uri endpoint (simpler for spike)
  6. Profile page displays DID, handle, and display name (fetched from public Bluesky API)
  7. Removed Bluesky posting feature, atproto_util.py, bsky_util.py
  8. Removed regex dependency — using stdlib re (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.json serves correct JSON with all required ATProto OAuth fields
  • /auth/login renders login form
  • /auth/ redirects to /auth/login when not authenticated
  • Public key in JWKS contains no private material (d field absent)

Dependencies

Flask>=3.0
authlib>=1.3
dnspython>=2.6
requests>=2.32
requests-hardened>=1.0.0b3
cryptography>=41.0

Deployment steps

  1. Copy spike/atproto-oauth/ to VPS at /srv/app/atproto-spike/
  2. Install deps in /srv/app/venv
  3. Set FLASK_SECRET_KEY env var
  4. Run flask --app app run --host 127.0.0.1 --port 8003
  5. 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.json during 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

  1. The cookbook code is well-structured and directly usable. The atproto_oauth.py and atproto_identity.py modules can transfer to production with minimal changes.
  2. Identity-only scope ("atproto") is confirmed supported by the spec. The sub field in the token response contains the DID.
  3. requests-hardened upgraded to 1.2.0 (stable, no longer beta). SSRF mitigations work.
  4. The authlib.jose module handles all JWT/JWK operations (no joserfc needed).
  5. For production: replace SQLite session store with the platform's user/session tables, mint platform JWT on successful auth, add error handling beyond bare assert statements.