Blame

064e69 Claude (MCP) 2026-03-15 01:47:53
[mcp] Add V3/V5 risk research: ATProto OAuth client and MCP OAuth AS
1
---
2
category: reference
3
tags: [research, auth, atproto, oauth, risk]
4
last_updated: 2026-03-14
5
confidence: high
6
---
7
8
# V3/V5 Risk Research: ATProto OAuth + MCP OAuth AS
9
10
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).
11
12
---
13
14
## V3: ATProto OAuth Client — Risk Assessment
15
16
### The cookbook demo is solid and directly usable
17
18
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`.
19
20
**Dependencies are minimal and mature:**
21
22
- `flask[dotenv]>=3` — framework
23
- `authlib>=1.3` — PKCE, JWK/JWT, code challenge. Widely used, actively maintained.
24
- `dnspython>=2.6` — DNS TXT lookups for handle resolution
25
- `requests>=2.32` — HTTP client
26
- `requests-hardened>=1.0.0b3` — SSRF mitigations (wraps requests)
27
- `regex>=2024.11.6` — Unicode-aware handle validation
28
29
No `joserfc` — the earlier search results were misleading. The demo uses `authlib.jose` for all JWT/JWK operations. This is good: one fewer dependency.
30
31
**The code is cleanly factored into four modules:**
32
33
- `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
34
- `atproto_identity.py` (~140 lines) — handle validation, DID resolution (DNS TXT + HTTP well-known), DID document resolution (plc.directory + did:web), bidirectional handle verification
35
- `atproto_security.py` — SSRF mitigations, safe URL validation
36
- `app.py` (~460 lines) — Flask routes: login, callback, client metadata, JWKS, logout, refresh, example post
37
38
**What we need to adapt (not rewrite):**
39
40
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.
41
42
Specific changes:
43
44
1. Replace Flask session cookie with platform JWT on `.robot.wtf` (the demo uses Flask's built-in encrypted session cookies)
45
2. Add signup flow (username selection) for new users
46
3. Store user records in our SQLite schema instead of the demo's oauth_session table
47
4. Change `OAUTH_SCOPE` from `"atproto repo:app.bsky.feed.post?action=create"` to `"atproto"` (identity-only, see below)
48
5. Serve client metadata at `https://robot.wtf/auth/client-metadata.json` (the demo computes it dynamically)
49
50
### Scope question: RESOLVED
51
52
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.
53
54
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.
55
56
### Remaining V3 risks
57
58
**Low risk:**
59
60
- **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.
61
- **Client metadata stability** — the `client_id` URL must be stable. We know the domain is `robot.wtf`, so this is settled.
62
63
**Medium risk:**
64
65
- **`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.
66
- **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.
67
68
**Already mitigated:**
69
70
- **ES256 key generation** — handled by `authlib.jose.JsonWebKey.generate_key("EC", "P-256")`
71
- **Handle-to-DID resolution** — the demo does both DNS TXT and HTTP well-known, with bidirectional verification
72
- **Token refresh** — implemented in the demo, including DPoP nonce rotation
73
74
### V3 verdict: LOW RISK
75
76
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.
77
78
---
79
80
## V5: MCP OAuth AS — Risk Assessment
81
82
### What Claude.ai needs
83
84
Based on research into Claude.ai's MCP OAuth behavior (GitHub issues, blog posts, working examples):
85
86
1. **Resource metadata** at `/.well-known/oauth-protected-resource` on the MCP endpoint (wiki subdomain). Returns `authorization_servers` array pointing to our AS.
87
2. **AS metadata** at `/.well-known/oauth-authorization-server` on the AS origin. Returns issuer, endpoints, supported grants/scopes.
88
3. **Dynamic Client Registration** (RFC 7591) endpoint. Claude.ai registers itself as a client, receives a `client_id` and `client_secret`.
89
4. **Authorization endpoint**. Claude.ai redirects the user here. We show a consent page (with ATProto login if needed).
90
5. **Token endpoint**. Claude.ai exchanges the authorization code for an access token.
91
6. **PKCE** is mandatory. Claude.ai sends `code_challenge` and `code_verifier`.
92
7. **`client_secret_post`** — Claude.ai uses this token endpoint auth method (not `client_secret_basic`).
93
94
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.
95
96
### authlib provides most of the machinery
97
98
authlib has a full Flask OAuth 2.0 server implementation with:
99
100
- `AuthorizationServer` class — handles grant registration, token generation, endpoint routing
101
- `AuthorizationCodeGrant` — implements the authorization code flow with PKCE support
102
- `ClientRegistrationEndpoint` (RFC 7591) — handles DCR with `save_client()` callback
103
- SQLAlchemy mixins for Client and Token models (or implement the interface manually for raw SQLite)
104
- JWT bearer token generation
105
- JWKS endpoint support
106
107
The official `authlib/example-oauth2-server` on GitHub shows the full Flask integration pattern.
108
109
### Implementation sketch
110
111
```python
112
from authlib.integrations.flask_oauth2 import AuthorizationServer
113
from authlib.oauth2.rfc7591 import ClientRegistrationEndpoint
114
from authlib.oauth2.rfc6749.grants import AuthorizationCodeGrant
115
116
server = AuthorizationServer(app, query_client=..., save_token=...)
117
118
# Register authorization code grant with PKCE
119
class MyCodeGrant(AuthorizationCodeGrant):
120
TOKEN_ENDPOINT_AUTH_METHODS = ['client_secret_post', 'none']
121
122
def save_authorization_code(self, code, request):
123
# Store code in SQLite
124
...
125
126
def query_authorization_code(self, code, client):
127
# Look up code from SQLite
128
...
129
130
def delete_authorization_code(self, authorization_code):
131
# Delete used code
132
...
133
134
def authenticate_user(self, authorization_code):
135
# Return user associated with the code
136
...
137
138
server.register_grant(MyCodeGrant)
139
140
# Register DCR endpoint
141
class MyDCR(ClientRegistrationEndpoint):
142
def authenticate_token(self, request):
143
return None # Open registration (Claude.ai needs this)
144
145
def save_client(self, client_info, client_metadata, request):
146
# Store in mcp_oauth_clients table
147
...
148
149
server.register_endpoint(MyDCR)
150
151
# Routes
152
@app.route('/auth/oauth/authorize', methods=['GET', 'POST'])
153
def authorize():
154
# Check platform JWT cookie → if not logged in, redirect to ATProto login
155
# Show consent page → user approves → server creates authorization code
156
...
157
158
@app.route('/auth/oauth/token', methods=['POST'])
159
def token():
160
return server.create_token_response()
161
162
@app.route('/auth/oauth/register', methods=['POST'])
163
def register():
164
return server.create_endpoint_response('client_registration')
165
```
166
167
### The metadata endpoints are trivial
168
169
```python
170
@app.route('/.well-known/oauth-authorization-server')
171
def as_metadata():
172
return jsonify({
173
"issuer": "https://robot.wtf",
174
"authorization_endpoint": "https://robot.wtf/auth/oauth/authorize",
175
"token_endpoint": "https://robot.wtf/auth/oauth/token",
176
"registration_endpoint": "https://robot.wtf/auth/oauth/register",
177
"response_types_supported": ["code"],
178
"grant_types_supported": ["authorization_code", "refresh_token"],
179
"code_challenge_methods_supported": ["S256"],
180
"token_endpoint_auth_methods_supported": ["client_secret_post", "none"],
181
"scopes_supported": ["wiki:read", "wiki:write"],
182
})
183
```
184
185
### Remaining V5 risks
186
187
**Medium risk:**
188
189
- **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.
190
- **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.
191
- **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.
192
193
**Low risk:**
194
195
- **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`.
196
- **PKCE** — authlib's `AuthorizationCodeGrant` supports S256 code challenges natively.
197
- **JWT token issuance** — we're already minting platform JWTs with authlib in V3. The MCP tokens are the same pattern with different claims.
198
199
**Already mitigated:**
200
201
- **JWKS endpoint** — the same RS256 public key serves both platform JWT validation and MCP token validation. One endpoint.
202
- **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.
203
204
### V5 verdict: MEDIUM RISK
205
206
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).
207
208
---
209
210
## Combined assessment
211
212
| Phase | Risk level | Why | Mitigation |
213
|-------|-----------|-----|------------|
214
| V3 | **Low** | Reference implementation exists, mature libraries, scope question resolved | Adapt the cookbook demo, test against non-Bluesky PDS |
215
| V5 | **Medium** | authlib provides the machinery, but Claude.ai's MCP client has underdocumented quirks | Keep it vanilla, budget a debugging cycle, test early |
216
217
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.