Design 1 · MCP endpoint on OAuth 2.1
The MCP endpoint becomes https://says.hermione.online/mcp — no secret in the URL. Callers present Authorization: Bearer <token>; tokens are short-lived, space-scoped, revocable, and issued by a small built-in authorization server. Because there are no user accounts, "logging in" at the consent page means pasting the space secret — the same credential as /space_admin/, typed exactly once per client instead of being embedded in every config file.
~/.claude.json) is inherent to every MCP client today — but a leaked refresh token is now revocable and space-scoped, unlike the current forever-secret.New HTTP surface
| Route | What |
|---|---|
/mcp | The MCP endpoint (Streamable HTTP, same JSON-RPC handler as today). Requires Authorization: Bearer; on failure replies 401 with WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource" — this header is how clients bootstrap the whole flow. |
/.well-known/oauth-protected-resource | RFC 9728: names the resource and points at the authorization server (same origin). |
/.well-known/oauth-authorization-server | RFC 8414: advertises the three endpoints below, code_challenge_methods_supported: ["S256"], grant types authorization_code + refresh_token, token_endpoint_auth_methods_supported: ["none"] (public clients; PKCE carries the security). |
/oauth/register | RFC 7591 dynamic client registration. Unauthenticated POST (per spec); stores client_id, name, redirect URIs. Claude registers itself here — no manual client setup ever. |
/oauth/authorize | The only human-facing page: shows the requesting client's name and redirect URI, asks for the space secret, and on approval 302-redirects with a single-use code. Rate-limited per IP. |
/oauth/token | Exchanges code + code_verifier for tokens; rotates refresh tokens. |
Data model (migration 0005_oauth)
Three new tables in main/models.py; the principal everywhere is the existing Space. All secrets are stored as SHA-256 hashes — the plaintext token exists only in the HTTP response that delivers it.
class OAuthClient(models.Model): # RFC 7591 registrations
client_id = models.CharField(max_length=64, unique=True) # token_urlsafe
name = models.CharField(max_length=128) # shown on consent page
redirect_uris = models.JSONField() # exact-match at authorize time
created_at = models.DateTimeField(auto_now_add=True)
class OAuthCode(models.Model): # authorization codes
code_hash = models.CharField(max_length=64, unique=True) # sha256(code)
client = models.ForeignKey(OAuthClient, on_delete=models.CASCADE)
space = models.ForeignKey(Space, on_delete=models.CASCADE)
redirect_uri = models.CharField(max_length=512)
code_challenge = models.CharField(max_length=128) # PKCE, S256 only
resource = models.CharField(max_length=256, blank=True) # RFC 8707 audience
expires_at = models.DateTimeField() # now + 5 min
used = models.BooleanField(default=False) # single-use
class OAuthToken(models.Model): # access + refresh, one table
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
kind = models.CharField(max_length=8) # 'access' | 'refresh'
client = models.ForeignKey(OAuthClient, on_delete=models.CASCADE)
space = models.ForeignKey(Space, related_name='oauth_tokens',
on_delete=models.CASCADE)
expires_at = models.DateTimeField() # access: 1 h · refresh: 90 d
revoked = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
Token policy
| Artifact | Lifetime | Rules |
|---|---|---|
| Authorization code | 5 minutes | Single-use; bound to client, redirect URI, PKCE challenge and space. Reuse → rejected. |
| Access token | 1 hour | Opaque secrets.token_urlsafe(43) with prefix sho_at_; sent only in the Authorization header (never query strings — that is the exact sin being retired). Verified per request: hash lookup + expiry + space.active. |
| Refresh token | 90 days, sliding | Prefix sho_rt_. Rotated on every use: the grant returns a new pair and revokes the old refresh token. Reuse of a revoked refresh token revokes the whole space's tokens (theft signal). |
| All of the above | — | Revoked in bulk by: secret rotation at /admin/, space suspension (checked live via the FK), or a future per-client revoke button. |
Enforcement — the only change main/mcp.py needs
The JSON-RPC machinery, tools and instructions are untouched. Authentication becomes a resolver in front of the same handler; the legacy URL route keeps working through it during migration.
# main/mcp.py — new entry point; tool code below it is unchanged
def _space_from_bearer(request):
auth = request.headers.get('Authorization', '')
if not auth.startswith('Bearer '):
return None
token_hash = hashlib.sha256(auth[7:].strip().encode()).hexdigest()
t = (OAuthToken.objects
.filter(token_hash=token_hash, kind='access', revoked=False,
expires_at__gt=timezone.now())
.select_related('space').first())
return t.space if t and t.space.active else None
@csrf_exempt
def mcp_oauth_endpoint(request): # routed at /mcp
space = _space_from_bearer(request)
if space is None:
resp = HttpResponse('Unauthorized', status=401)
resp['WWW-Authenticate'] = (
'Bearer resource_metadata='
f'"https://{request.get_host()}/.well-known/oauth-protected-resource"')
return resp
return _dispatch(request, space) # today's body of mcp_endpoint
The consent page (the one new human touchpoint)
A sibling of the existing space_admin_login.html: it shows "‹client name› wants to publish to a space on says.hermione.online", the redirect URI, one password field for the space secret, and Approve / Deny. On approve it resolves the space (constant-time, like today's logins), mints the code, and redirects. Wrong secret and per-IP rate limiting reuse the same error UX as the admin logins. If the browser already has a /space_admin/ session, the secret field can be pre-satisfied by that session — nice-to-have, not required.
What connecting looks like afterwards
# claude.ai → Settings → Connectors → Add custom connector
URL: https://says.hermione.online/mcp
→ browser popup opens /oauth/authorize → paste space secret → Approve. Done.
# Claude Code
claude mcp add --transport http says-hermione https://says.hermione.online/mcp
→ on first use: /mcp → Authenticate → same browser consent. Done.
No secret in the command line, in ~/.claude.json project config, or in any URL — only short-lived tokens managed by the client.
Considered and rejected
| Alternative | Verdict | Why |
|---|---|---|
Static Bearer header (claude mcp add --header "Authorization: Bearer <secret>") | interim only | One-line change and gets the secret out of URLs — but it's still a forever-credential in every client config, claude.ai connectors push you toward OAuth anyway, and you'd build the revocation story twice. Acceptable as a stopgap if OAuth slips. |
django-oauth-toolkit | no | Wants django.contrib.auth Users as resource owners; bending it to Space-as-principal costs more than the ~400 hand-rolled lines, and adds a dependency to a deliberately dependency-light app. |
| JWT access tokens | no | Single-server, single-DB app — an indexed hash lookup is simpler, revocable, and adds no crypto surface. |
| Third-party IdP (Google etc.) | no | The platform has no user accounts by design; introducing them for OAuth's sake inverts the architecture. |
Honest limits
- The space secret remains a single root credential; whoever has it can mint tokens. That is the platform's deliberate auth model — this design shrinks its exposure, not its power.
- MCP clients persist refresh tokens in local plaintext config. Industry-wide today; mitigated by rotation, expiry and bulk revocation.
- The consent page's assurance is only as strong as HTTPS + the rate limiter; there is no second factor. Realistic for this platform's threat model.
Implementation checklist
| File | Change |
|---|---|
| main/models.py | + OAuthClient, OAuthCode, OAuthToken; Space.rotate_secret() also revokes tokens; migration 0005 |
| main/oauth.py (new) | metadata views, register, authorize (GET form / POST consent), token (code + refresh grants), rate limiter (~400 lines) |
| main/mcp.py | split mcp_endpoint into resolver + _dispatch; add mcp_oauth_endpoint; legacy route honors Space.url_secret_enabled |
| main/urls.py | routes for /mcp, /oauth/…, /.well-known/… (before the project catch-alls) |
| main/naming.py | add oauth to RESERVED_DIRECTORY_NAMES |
| templates | oauth_authorize.html (consent), reusing admin-login styling |
| main/tests.py | discovery docs · DCR · full code+PKCE happy path · wrong verifier · code reuse · expired/revoked token → 401 with header · refresh rotation + reuse-theft · rotation/suspension revokes · legacy flag on/off |