Properties
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— frameworkauthlib>=1.3— PKCE, JWK/JWT, code challenge. Widely used, actively maintained.dnspython>=2.6— DNS TXT lookups for handle resolutionrequests>=2.32— HTTP clientrequests-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 requestsatproto_identity.py(~140 lines) — handle validation, DID resolution (DNS TXT + HTTP well-known), DID document resolution (plc.directory + did:web), bidirectional handle verificationatproto_security.py— SSRF mitigations, safe URL validationapp.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:
- Replace Flask session cookie with platform JWT on
.robot.wtf(the demo uses Flask's built-in encrypted session cookies) - Add signup flow (username selection) for new users
- Store user records in our SQLite schema instead of the demo's oauth_session table
- Change
OAUTH_SCOPEfrom"atproto repo:app.bsky.feed.post?action=create"to"atproto"(identity-only, see below) - 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_idURL must be stable. We know the domain isrobot.wtf, so this is settled.
Medium risk:
requests-hardenedmaturity — the library is at1.0.0b3(beta). It wrapsrequeststo prevent SSRF. If it causes issues, we can replace it with manual URL validation + standardrequests. 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):
- Resource metadata at
/.well-known/oauth-protected-resourceon the MCP endpoint (wiki subdomain). Returnsauthorization_serversarray pointing to our AS. - AS metadata at
/.well-known/oauth-authorization-serveron the AS origin. Returns issuer, endpoints, supported grants/scopes. - Dynamic Client Registration (RFC 7591) endpoint. Claude.ai registers itself as a client, receives a
client_idandclient_secret. - Authorization endpoint. Claude.ai redirects the user here. We show a consent page (with ATProto login if needed).
- Token endpoint. Claude.ai exchanges the authorization code for an access token.
- PKCE is mandatory. Claude.ai sends
code_challengeandcode_verifier. client_secret_post— Claude.ai uses this token endpoint auth method (notclient_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:
AuthorizationServerclass — handles grant registration, token generation, endpoint routingAuthorizationCodeGrant— implements the authorization code flow with PKCE supportClientRegistrationEndpoint(RFC 7591) — handles DCR withsave_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
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
@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-Authenticateheader 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
initializebefore authenticating. The MCP endpoint needs to allow theinitializeJSON-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
ClientRegistrationEndpointhandles this. Claude.ai sendsclient_name,redirect_uris, andgrant_types. We store them and return aclient_id+client_secret. - PKCE — authlib's
AuthorizationCodeGrantsupports 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.