A real authentication layer for says.hermione.online
Three additive changes: (1) the MCP endpoint moves from secret-in-URL to spec-compliant OAuth 2.1 with short-lived Bearer tokens, (2) any published project can be gated with an htaccess-style read_access_password (HTTP Basic Auth) that works identically for humans in a browser and for agents with an Authorization header, and (3) /space_admin/ learns concurrent multi-space sessions. All ship separately; none breaks anything that works today.
/space_admin/. Read = read_access_password: the published site is public unless a project sets one. The read password never gates MCP — the space's own agent always reads its own content with the space credential.What exists today
| Surface | Current auth | Weakness |
|---|---|---|
/mcp/<secret>/ | 44-char secret in the URL path; knowing the URL is the auth | URLs leak into proxy/access logs, shell history (claude mcp add …), plaintext client configs, pasted messages. One credential, no expiry, no scoping — and it doubles as the /space_admin/ login, so a leak is total. This is the only place a secret rides in a URL. |
/space_admin/ | POST form → session cookie, secret re-verified per request (rotation ends the session), logout button | Mechanism is fine and the secret never touches a URL — but only one space per browser: a second login overwrites the first, and logout flush()es the whole session, killing an /admin/ login alongside it. |
/admin/ | Session login with ADMIN_PASSWORD from .env | Acceptable for a single-admin platform; out of scope here (add rate limiting, see below). |
/<project>/… | None — everything available_online is world-readable | No way to publish something for a limited audience; "unlisted" is the only privacy lever. |
read_access_password. The space secret survives — demoted from "the URL everyone pastes everywhere" to a root credential used only on first-party login pages.The three designs
1 · MCP (writes): OAuth 2.1, as the MCP spec wants it ~400 lines
The app becomes both OAuth authorization server and resource server (same hand-rolled, no-dependency spirit as main/mcp.py). New endpoint /mcp authenticates with Authorization: Bearer; discovery (RFC 9728/RFC 8414), dynamic client registration (RFC 7591) and PKCE make Claude.ai connectors and Claude Code connect with zero manual token handling: the human just pastes the space secret once into a consent page. Legacy /mcp/<secret>/ stays alive behind a per-space flag during migration.
Full design → sequence diagram, data model, token policy, code sketches
2 · Published pages (reads): per-project read_access_password ~150 lines
One optional read password per project (Directory.read_access_password) — the moral equivalent of dropping an .htaccess into a folder. Django serves the 401/WWW-Authenticate Basic Auth dance itself (there is no Apache here; content lives in sqlite). Browsers show their native login prompt; agents send one header or use curl -u. Password set over MCP (set_read_access_password) or in /space_admin/; read-protected projects never leak their title/description into index pages.
Full design → request flow, model change, index-leak rules, alternatives
3 · /space_admin/: many spaces, one browser ~80 lines
The login is already form-based and cookie-backed — the secret never rides in a URL here. What's missing is concurrency: the session holds exactly one space, so a second login evicts the first, and logout flush()es everything. Design below on this page: the session stores a dict of authenticated spaces, the space id (public info) moves into the path, and each space gets its own logout.
Design 3 · Multi-space /space_admin/ sessions
No model change and no new credential — this reshapes where the session keeps what it already keeps. One signed session cookie (Django's, already Secure + HttpOnly in production) holds a map instead of a scalar:
# session['space_admins'] — was: session['space_admin_secret'] = <one secret>
{ "1": "<secret of space 1>", "7": "<secret of space 7>", ... } # cap at ~10
Storing the secret (not just the id) preserves today's key property: every request re-resolves Space.objects.filter(pk=id, secret=stored), so rotating a space's secret instantly ends that space's sessions — now independently per space. A single dict in one cookie behaves exactly like "one cookie per space" (add, drop, verify each login independently) while keeping Django's signing, expiry and flags for free — a literal per-space cookie would add path-scoping quirks and buy nothing.
| Route | What |
|---|---|
/space_admin/ | The hub: login form (space secret → adds an entry to the dict, redirects to that space's dashboard) plus the list of spaces currently logged in, each re-verified live. With exactly one active login it just redirects to it. |
/space_admin/<space_id>/ | That space's dashboard — today's page, addressable per space so several admins/tabs coexist. The id in the path is public info (it's the space index URL id); no secret ever appears in a URL. 404 behaves like an unknown space when the session has no entry for it. |
/space_admin/<space_id>/toggle|delete|… | Today's actions, nested under the space they act on; the decorator resolves the space from path id + session entry instead of the single session scalar. |
/space_admin/<space_id>/logout/ | Pops one entry from the dict — other space logins and any /admin/ session in the same browser survive (today's session.flush() kills them all). A separate "log out of all spaces" clears just the dict. |
Suspension semantics carry over per entry: a suspended space still resolves for its owner, shows the suspension note, and is read-only — unchanged from today. Synergy with Design 1: the OAuth consent page can offer “continue as space N” for spaces already in the dict, skipping the secret paste. Touches: views.py (session helpers + decorator, ~50 lines), urls.py (nest the routes), space_admin_login.html/space_admin_dashboard.html (space switcher + per-space logout). Tests: two spaces logged in simultaneously · rotation ends only its own · logout keeps the sibling and the site-admin session · path id without session entry → login redirect.
Cross-cutting decisions (recommended)
| Decision | Recommendation | Why |
|---|---|---|
| Role of the space secret | Keep it, as the root credential: it logs you into /space_admin/ and into the new OAuth consent page. It stops appearing in MCP URLs and client configs. | No user accounts exist and none are needed; the secret already is the identity of a space. |
| Secret rotation semantics | rotate_secret() additionally revokes all OAuth tokens of the space. | Matches the existing behavior where rotation kills /space_admin/ sessions; one lever cuts everything. |
| Token storage | Only SHA-256 hashes of tokens/codes in sqlite; plaintext exists once, in the HTTP response. | A leaked db.sqlite3 (or backup) must not mint working credentials. |
| Library vs hand-rolled | Hand-rolled main/oauth.py. | django-oauth-toolkit assumes django.contrib.auth Users; here the principal is a Space. The needed subset of OAuth 2.1 is ~400 lines — consistent with the hand-rolled MCP server. |
| Reserved names | Add oauth to RESERVED_DIRECTORY_NAMES in naming.py. | New top-level routes /oauth/… must not be claimable as a project name. (.well-known is safe already — names can't start with a dot.) |
| Rate limiting | One tiny shared helper: per-IP counter (LocMem cache), applied to /oauth/authorize POST, both admin logins, and failed Basic Auth attempts. ~30 lines. | Every design here ultimately guards a password/secret form; brute force is the realistic attack. |
| Two domains | OAuth metadata is generated from the request's Host, so says.hermione.online and says.hermiona.online each work as a self-consistent issuer; tokens are accepted on both. | Strict clients validate that the advertised resource matches the URL they connected to (RFC 9728); hardcoding the primary domain would break connecting via the mirror. |
Rollout plan
| Phase | Ships | Breaks |
|---|---|---|
| 1 — Web gate + multi-space admin | Migration 0004 (Directory.read_access_password), the 401 check in views.py, the set_read_access_password MCP tool, /space_admin/ UI, rate-limit helper; the Design 3 session/route reshape (no migration). | Nothing — projects without a read password behave exactly as today; existing single-space admin sessions just re-login once. |
| 2 — OAuth alongside | main/oauth.py (3 models, 5 endpoints), /mcp Bearer route reusing the existing JSON-RPC handler, consent template. Legacy /mcp/<secret>/ untouched. | Nothing — both auth paths resolve to the same Space and the same tool code. |
| 3 — Migrate & retire | Re-add the connector in claude.ai / Claude Code via OAuth; per-space url_secret_enabled flag: default on for existing spaces, off for new ones; flip old spaces off after their owners migrate; eventually delete the legacy route. | Only clients still on the old URL, knowingly, at flip time. |
What deliberately does not change
- MCP-only writes, text-only files, no
delete_directory/move_file— untouched. available_onlinestays human-only and invisible over MCP; the newread_protectedflag is not secret (the agent sets the password, it may know the gate exists).- MCP
read_fileis never gated byread_access_password— the read password guards the public web surface only. - Serve-time analytics injection keeps working on gated pages (auth happens before
_serve()). /admin/keepsADMIN_PASSWORD; it only gains rate limiting.