Commit 064e69

2026-03-15 01:47:53 Claude (MCP): [mcp] Add V3/V5 risk research: ATProto OAuth client and MCP OAuth AS
/dev/null .. Dev/V3_V5_Risk_Research.md
@@ 0,0 1,217 @@
+ ---
+ category: reference
+ tags: [research, auth, atproto, oauth, risk]
+ last_updated: 2026-03-14
+ confidence: high
+ ---
+
+ # V3/V5 Risk Research: ATProto OAuth + MCP OAuth AS
+
+ Research to de-risk the two hardest phases: V3 (ATProto OAuth client for browser login) and V5 (self-hosted OAuth 2.1 AS for Claude.ai MCP).
+
+ ---
+
+ ## V3: ATProto OAuth Client — Risk Assessment
+
+ ### The cookbook demo is solid and directly usable
+
+ The Bluesky cookbook Flask demo (`bluesky-social/cookbook/python-oauth-web-app`) is a well-structured, ~600-line implementation written by Bryan Newbold (Bluesky protocol team). CC-0 licensed. It implements the full confidential client flow and has been deployed to `oauth-flask.demo.bsky.dev`.
+
+ **Dependencies are minimal and mature:**
+
+ - `flask[dotenv]>=3` — framework
+ - `authlib>=1.3` — PKCE, JWK/JWT, code challenge. Widely used, actively maintained.
+ - `dnspython>=2.6` — DNS TXT lookups for handle resolution
+ - `requests>=2.32` — HTTP client
+ - `requests-hardened>=1.0.0b3` — SSRF mitigations (wraps requests)
+ - `regex>=2024.11.6` — Unicode-aware handle validation
+
+ No `joserfc` — the earlier search results were misleading. The demo uses `authlib.jose` for all JWT/JWK operations. This is good: one fewer dependency.
+
+ **The code is cleanly factored into four modules:**
+
+ - `atproto_oauth.py` (~430 lines) — the complete OAuth flow: AS metadata validation, PAR, DPoP proof generation, token exchange, token refresh, DPoP nonce retry, PDS authenticated requests
+ - `atproto_identity.py` (~140 lines) — handle validation, DID resolution (DNS TXT + HTTP well-known), DID document resolution (plc.directory + did:web), bidirectional handle verification
+ - `atproto_security.py` — SSRF mitigations, safe URL validation
+ - `app.py` (~460 lines) — Flask routes: login, callback, client metadata, JWKS, logout, refresh, example post
+
+ **What we need to adapt (not rewrite):**
+
+ The demo's `app.py` has the Flask routes interleaved with a "post to Bluesky" demo feature. We'd strip that out and replace it with our platform JWT minting + redirect-to-dashboard flow. The `atproto_oauth.py` and `atproto_identity.py` modules can be used essentially as-is.
+
+ Specific changes:
+
+ 1. Replace Flask session cookie with platform JWT on `.robot.wtf` (the demo uses Flask's built-in encrypted session cookies)
+ 2. Add signup flow (username selection) for new users
+ 3. Store user records in our SQLite schema instead of the demo's oauth_session table
+ 4. Change `OAUTH_SCOPE` from `"atproto repo:app.bsky.feed.post?action=create"` to `"atproto"` (identity-only, see below)
+ 5. Serve client metadata at `https://robot.wtf/auth/client-metadata.json` (the demo computes it dynamically)
+
+ ### Scope question: RESOLVED
+
+ The ATProto OAuth spec is explicit: a client can request scope `"atproto"` alone for identity-only authentication. The spec says: "A client may include only the atproto scope if they only need account authentication - for example a 'Login with atproto' use case." The `sub` field in the token response contains the DID.
+
+ This is exactly our use case. We don't need PDS write access. We just need to prove the user owns the DID. Request `"atproto"` as the scope, verify the `sub` in the token response, and we're done.
+
+ ### Remaining V3 risks
+
+ **Low risk:**
+
+ - **DPoP nonce handling** — the demo already implements the retry-on-nonce-error pattern for both AS and PDS interactions. This is the most commonly reported pain point in ATProto OAuth, and the demo handles it.
+ - **Client metadata stability** — the `client_id` URL must be stable. We know the domain is `robot.wtf`, so this is settled.
+
+ **Medium risk:**
+
+ - **`requests-hardened` maturity** — the library is at `1.0.0b3` (beta). It wraps `requests` to prevent SSRF. If it causes issues, we can replace it with manual URL validation + standard `requests`. The SSRF mitigations are important but not complex.
+ - **PDS compatibility** — the demo was tested against bsky.social's PDS. Users on independent PDS instances might have different AS behaviors. The spec is the spec, but edge cases are possible. Mitigate by testing against at least one non-Bluesky PDS.
+
+ **Already mitigated:**
+
+ - **ES256 key generation** — handled by `authlib.jose.JsonWebKey.generate_key("EC", "P-256")`
+ - **Handle-to-DID resolution** — the demo does both DNS TXT and HTTP well-known, with bidirectional verification
+ - **Token refresh** — implemented in the demo, including DPoP nonce rotation
+
+ ### V3 verdict: LOW RISK
+
+ The reference implementation exists, works, is well-tested, uses mature dependencies, and maps almost directly to our use case. The adaptation work is straightforward — it's mostly removing the Bluesky posting demo and wiring in our user management.
+
+ ---
+
+ ## V5: MCP OAuth AS — Risk Assessment
+
+ ### What Claude.ai needs
+
+ Based on research into Claude.ai's MCP OAuth behavior (GitHub issues, blog posts, working examples):
+
+ 1. **Resource metadata** at `/.well-known/oauth-protected-resource` on the MCP endpoint (wiki subdomain). Returns `authorization_servers` array pointing to our AS.
+ 2. **AS metadata** at `/.well-known/oauth-authorization-server` on the AS origin. Returns issuer, endpoints, supported grants/scopes.
+ 3. **Dynamic Client Registration** (RFC 7591) endpoint. Claude.ai registers itself as a client, receives a `client_id` and `client_secret`.
+ 4. **Authorization endpoint**. Claude.ai redirects the user here. We show a consent page (with ATProto login if needed).
+ 5. **Token endpoint**. Claude.ai exchanges the authorization code for an access token.
+ 6. **PKCE** is mandatory. Claude.ai sends `code_challenge` and `code_verifier`.
+ 7. **`client_secret_post`** — Claude.ai uses this token endpoint auth method (not `client_secret_basic`).
+
+ Claude.ai does NOT require DPoP for MCP OAuth. The ATProto OAuth profile mandates DPoP, but Claude.ai's MCP client uses standard OAuth 2.1 — a much simpler profile.
+
+ ### authlib provides most of the machinery
+
+ authlib has a full Flask OAuth 2.0 server implementation with:
+
+ - `AuthorizationServer` class — handles grant registration, token generation, endpoint routing
+ - `AuthorizationCodeGrant` — implements the authorization code flow with PKCE support
+ - `ClientRegistrationEndpoint` (RFC 7591) — handles DCR with `save_client()` callback
+ - SQLAlchemy mixins for Client and Token models (or implement the interface manually for raw SQLite)
+ - JWT bearer token generation
+ - JWKS endpoint support
+
+ The official `authlib/example-oauth2-server` on GitHub shows the full Flask integration pattern.
+
+ ### Implementation sketch
+
+ ```python
+ from authlib.integrations.flask_oauth2 import AuthorizationServer
+ from authlib.oauth2.rfc7591 import ClientRegistrationEndpoint
+ from authlib.oauth2.rfc6749.grants import AuthorizationCodeGrant
+
+ server = AuthorizationServer(app, query_client=..., save_token=...)
+
+ # Register authorization code grant with PKCE
+ class MyCodeGrant(AuthorizationCodeGrant):
+ TOKEN_ENDPOINT_AUTH_METHODS = ['client_secret_post', 'none']
+
+ def save_authorization_code(self, code, request):
+ # Store code in SQLite
+ ...
+
+ def query_authorization_code(self, code, client):
+ # Look up code from SQLite
+ ...
+
+ def delete_authorization_code(self, authorization_code):
+ # Delete used code
+ ...
+
+ def authenticate_user(self, authorization_code):
+ # Return user associated with the code
+ ...
+
+ server.register_grant(MyCodeGrant)
+
+ # Register DCR endpoint
+ class MyDCR(ClientRegistrationEndpoint):
+ def authenticate_token(self, request):
+ return None # Open registration (Claude.ai needs this)
+
+ def save_client(self, client_info, client_metadata, request):
+ # Store in mcp_oauth_clients table
+ ...
+
+ server.register_endpoint(MyDCR)
+
+ # Routes
+ @app.route('/auth/oauth/authorize', methods=['GET', 'POST'])
+ def authorize():
+ # Check platform JWT cookie → if not logged in, redirect to ATProto login
+ # Show consent page → user approves → server creates authorization code
+ ...
+
+ @app.route('/auth/oauth/token', methods=['POST'])
+ def token():
+ return server.create_token_response()
+
+ @app.route('/auth/oauth/register', methods=['POST'])
+ def register():
+ return server.create_endpoint_response('client_registration')
+ ```
+
+ ### The metadata endpoints are trivial
+
+ ```python
+ @app.route('/.well-known/oauth-authorization-server')
+ def as_metadata():
+ return jsonify({
+ "issuer": "https://robot.wtf",
+ "authorization_endpoint": "https://robot.wtf/auth/oauth/authorize",
+ "token_endpoint": "https://robot.wtf/auth/oauth/token",
+ "registration_endpoint": "https://robot.wtf/auth/oauth/register",
+ "response_types_supported": ["code"],
+ "grant_types_supported": ["authorization_code", "refresh_token"],
+ "code_challenge_methods_supported": ["S256"],
+ "token_endpoint_auth_methods_supported": ["client_secret_post", "none"],
+ "scopes_supported": ["wiki:read", "wiki:write"],
+ })
+ ```
+
+ ### Remaining V5 risks
+
+ **Medium risk:**
+
+ - **Claude.ai client quirks** — multiple GitHub issues document finicky behavior: specific expectations about the `WWW-Authenticate` header on 401 responses, exact JSON shapes in metadata, whether CORS headers are needed, and how the callback URL works. The spec is clear but Claude.ai's implementation has been evolving. Plan for a debugging cycle.
+ - **Token refresh** — Claude.ai needs to refresh tokens without user interaction. The authlib server supports refresh tokens, but the lifecycle (how long until Claude.ai tries to refresh, what happens on failure) needs testing.
+ - **Selective auth for MCP initialize** — several sources note that MCP clients need to call `initialize` before authenticating. The MCP endpoint needs to allow the `initialize` JSON-RPC method without a token, then require auth for tool calls. This is middleware logic, not an OAuth concern, but it needs to be right.
+
+ **Low risk:**
+
+ - **DCR implementation** — authlib's `ClientRegistrationEndpoint` handles this. Claude.ai sends `client_name`, `redirect_uris`, and `grant_types`. We store them and return a `client_id` + `client_secret`.
+ - **PKCE** — authlib's `AuthorizationCodeGrant` supports S256 code challenges natively.
+ - **JWT token issuance** — we're already minting platform JWTs with authlib in V3. The MCP tokens are the same pattern with different claims.
+
+ **Already mitigated:**
+
+ - **JWKS endpoint** — the same RS256 public key serves both platform JWT validation and MCP token validation. One endpoint.
+ - **Authorization UI** — the consent page is simple HTML. If the user has a platform JWT cookie, show "Authorize Claude to access {wiki}?". If not, redirect to the ATProto login flow (V3) first. This is just Flask view logic.
+
+ ### V5 verdict: MEDIUM RISK
+
+ The spec surface is well-defined and authlib provides the building blocks. The risk is not in the core implementation but in Claude.ai's specific client behavior — underdocumented edge cases that require empirical testing. The mitigation is to budget a debugging cycle and keep the implementation as vanilla OAuth 2.1 as possible (no extensions Claude.ai might not support).
+
+ ---
+
+ ## Combined assessment
+
+ | Phase | Risk level | Why | Mitigation |
+ |-------|-----------|-----|------------|
+ | V3 | **Low** | Reference implementation exists, mature libraries, scope question resolved | Adapt the cookbook demo, test against non-Bluesky PDS |
+ | V5 | **Medium** | authlib provides the machinery, but Claude.ai's MCP client has underdocumented quirks | Keep it vanilla, budget a debugging cycle, test early |
+
+ The biggest risk reduction available right now: **test V5 early by building a minimal throwaway OAuth AS** (just the metadata + DCR + authorize + token endpoints, no real auth) and pointing Claude.ai at it. If Claude.ai can complete the flow against a stub, the real implementation is just wiring in the ATProto login and SQLite persistence. If it can't, we'll learn exactly what it needs before building the full thing.
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9