A real authentication layer for says.hermione.online

Design proposal · 2026-08-28 · based on the repo at commit d162dc1 (post-Spaces, hand-rolled MCP in main/mcp.py)

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.

The model in one line Write = the space credential: OAuth over MCP (rooted in the space secret), plus the space secret itself for humans at /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

SurfaceCurrent authWeakness
/mcp/<secret>/44-char secret in the URL path; knowing the URL is the authURLs 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 buttonMechanism 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 .envAcceptable for a single-admin platform; out of scope here (add rate limiting, see below).
/<project>/…None — everything available_online is world-readableNo way to publish something for a limited audience; "unlisted" is the only privacy lever.
TODAY TARGET MCP client Claude / agent /mcp/<secret>/ secret in URL path URL = key Any visitor human, agent, crawler /<project>/ always public no gate · secret lands in logs, shell history, client configs · one string = full control of the space, forever · nothing published can be access-restricted MCP client · WRITE OAuth 2.1 + PKCE /mcp Bearer token, header expiring Visitor / agent · READ browser prompt or -u flag /<project>/ read_access_password 401 → pwd · tokens expire, rotate, and are revocable per space · the space secret is only ever typed on a consent page · any project can set an htaccess-style read password
The core changes at a glance: writes gated by the space credential (OAuth), reads optionally gated per project by 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.

Full design → session shape, routes, semantics

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.

RouteWhat
/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)

DecisionRecommendationWhy
Role of the space secretKeep 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 semanticsrotate_secret() additionally revokes all OAuth tokens of the space.Matches the existing behavior where rotation kills /space_admin/ sessions; one lever cuts everything.
Token storageOnly 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-rolledHand-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 namesAdd 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 limitingOne 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 domainsOAuth 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

PhaseShipsBreaks
1 — Web gate + multi-space adminMigration 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 alongsidemain/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 & retireRe-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

Continue: Design 1 — MCP OAuth 2.1 · Design 2 — htaccess-style web gate · Design 3 — multi-space admin sessions