Blame
|
1 | --- |
||||||
| 2 | category: reference |
|||||||
| 3 | tags: [spike, atproto, oauth, auth] |
|||||||
| 4 | last_updated: 2026-03-15 |
|||||||
| 5 | confidence: high |
|||||||
| 6 | --- |
|||||||
| 7 | ||||||||
| 8 | # VS-1: ATProto OAuth Spike |
|||||||
| 9 | ||||||||
| 10 | ## Status: COMPLETE — ready for manual deployment and testing |
|||||||
| 11 | ||||||||
| 12 | ## What was built |
|||||||
| 13 | ||||||||
| 14 | A throwaway spike adapting the [Bluesky cookbook Flask OAuth demo](https://github.com/bluesky-social/cookbook/tree/main/python-oauth-web-app) (CC-0) for robot.wtf identity-only authentication. |
|||||||
| 15 | ||||||||
| 16 | **Branch:** `feat/vs-1-atproto-spike` in the robot.wtf repo |
|||||||
| 17 | **Location:** `spike/atproto-oauth/` |
|||||||
| 18 | ||||||||
| 19 | ## Key adaptations from cookbook demo |
|||||||
| 20 | ||||||||
| 21 | 1. **Scope changed to `"atproto"`** — identity-only, no PDS write access. The ATProto spec explicitly supports this for "Login with atproto" use cases. |
|||||||
| 22 | 2. **Fixed client_id** at `https://robot.wtf/auth/client-metadata.json` (cookbook dynamically computes it from request host) |
|||||||
| 23 | 3. **All routes under `/auth/`** prefix to match Caddy routing |
|||||||
| 24 | 4. **JWK loaded from file** (`/srv/data/client_jwk.json`) instead of environment variable |
|||||||
| 25 | 5. **Inline JWKS** in client metadata instead of separate `jwks_uri` endpoint (simpler for spike) |
|||||||
| 26 | 6. **Profile page** displays DID, handle, and display name (fetched from public Bluesky API) |
|||||||
| 27 | 7. **Removed** Bluesky posting feature, `atproto_util.py`, `bsky_util.py` |
|||||||
| 28 | 8. **Removed** `regex` dependency — using stdlib `re` (no Unicode handle validation needed for spike) |
|||||||
| 29 | ||||||||
| 30 | ## Files |
|||||||
| 31 | ||||||||
| 32 | | File | Lines | Source | |
|||||||
| 33 | |------|-------|--------| |
|||||||
| 34 | | `app.py` | ~280 | Heavily adapted from cookbook `app.py` | |
|||||||
| 35 | | `atproto_oauth.py` | ~230 | Cookbook, removed `pds_authed_req` | |
|||||||
| 36 | | `atproto_identity.py` | ~100 | Cookbook, unchanged | |
|||||||
| 37 | | `atproto_security.py` | ~40 | Cookbook, unchanged | |
|||||||
| 38 | | `schema.sql` | ~20 | Cookbook, unchanged | |
|||||||
| 39 | | `templates/` | 4 files | New (simpler than cookbook) | |
|||||||
| 40 | | `requirements.txt` | 6 deps | Subset of cookbook | |
|||||||
| 41 | ||||||||
| 42 | ## Smoke test results |
|||||||
| 43 | ||||||||
| 44 | - Flask app starts and initializes SQLite database |
|||||||
| 45 | - `/auth/client-metadata.json` serves correct JSON with all required ATProto OAuth fields |
|||||||
| 46 | - `/auth/login` renders login form |
|||||||
| 47 | - `/auth/` redirects to `/auth/login` when not authenticated |
|||||||
| 48 | - Public key in JWKS contains no private material (`d` field absent) |
|||||||
| 49 | ||||||||
| 50 | ## Dependencies |
|||||||
| 51 | ||||||||
| 52 | ``` |
|||||||
| 53 | Flask>=3.0 |
|||||||
| 54 | authlib>=1.3 |
|||||||
| 55 | dnspython>=2.6 |
|||||||
| 56 | requests>=2.32 |
|||||||
| 57 | requests-hardened>=1.0.0b3 |
|||||||
| 58 | cryptography>=41.0 |
|||||||
| 59 | ``` |
|||||||
| 60 | ||||||||
| 61 | ## Deployment steps |
|||||||
| 62 | ||||||||
| 63 | 1. Copy `spike/atproto-oauth/` to VPS at `/srv/app/atproto-spike/` |
|||||||
| 64 | 2. Install deps in `/srv/app/venv` |
|||||||
| 65 | 3. Set `FLASK_SECRET_KEY` env var |
|||||||
| 66 | 4. Run `flask --app app run --host 127.0.0.1 --port 8003` |
|||||||
| 67 | 5. Test at `https://robot.wtf/auth/login` |
|||||||
| 68 | ||||||||
| 69 | ## What to watch for during manual testing |
|||||||
| 70 | ||||||||
| 71 | - **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"`. |
|||||||
| 72 | - **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. |
|||||||
| 73 | - **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. |
|||||||
| 74 | - **Session cookie**: Requires `FLASK_SECRET_KEY`. Without it, Flask will error on any session operation. |
|||||||
| 75 | ||||||||
| 76 | ## Findings for V3 production implementation |
|||||||
| 77 | ||||||||
| 78 | 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. |
|||||||
| 79 | 2. Identity-only scope (`"atproto"`) is confirmed supported by the spec. The `sub` field in the token response contains the DID. |
|||||||
| 80 | 3. `requests-hardened` upgraded to 1.2.0 (stable, no longer beta). SSRF mitigations work. |
|||||||
| 81 | 4. The `authlib.jose` module handles all JWT/JWK operations (no `joserfc` needed). |
|||||||
| 82 | 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. |
|||||||