The private notes HTML store shipped 2026-08-04, but not via the mod_auth_form/mod_session design below — those modules were never enabled. What actually shipped: the private gdata-server runs with NOTES_WEB=true, NOTES_WEB_PREFIX=/private, NOTES_WEB_AUTH=true, 1h configurable session TTL. notes_web.py gained an opt-in gate that accepts a session cookie or Authorization: Bearer, both validated against the shared gdata_oauth token store. GET /private/login (Apache-guarded by AuthType Basic + .htpasswd-webdav) mints a short-lived token and sets an HttpOnly; Secure; SameSite=Lax; Path=/private cookie, with open-redirect-safe next=. Apache exposes it via a ProxyPassMatch that excludes the reserved /private/{mcp,oauth,authorize,login,.well-known} paths (a broad <Location /private/> shadowed them — reverted). No mod_session/mod_auth_form/mod_lua. Caveat: the cookie token is a full private-store bearer credential for its 1h life (shared store). Details in gdata-server/todo (2026-08-04 entries); code in the private-notes commit. The Phase-2 MCP-bearer plan below remains just a plan.
Replace per-store Python OAuth with Apache-managed session auth backed by the WebDAV htpasswd file. One set of credentials, no separate per-store passwords. Phase 1: private notes HTML renderer. Phase 2: MCP token auth.
/etc/apache2/.htpasswd-webdav -- same file used by webdav.critchley.biz. One username/password to manage. Validated by Apache mod_authn_file, no Python involved.
The HTML renderer already exists in gdata-server (the NOTES_WEB=true flag enables it). It is currently disabled on the private store (NOTES_WEB=false). All that is needed:
1. Enable NOTES_WEB=true on the private notes server instance
2. Apache proxies /private/html/ to localhost:8120/, gated behind session auth
Port 8120 is not externally accessible (only via stunnel on 18121 and Apache proxy), so enabling NOTES_WEB on it does not expose the renderer publicly.
Level 1 -- Session cookie (browser, 1 week)
Used by the HTML renderer in a browser.
Issued by Apache after a form login (mod_auth_form).
Stored as an encrypted, signed cookie (mod_session_cookie + mod_session_crypto).
SessionMaxAge 604800 (1 week).
Automatically sent by browser on every request -- no JS needed.
Level 2 -- Bearer token (API/scripts/MCP, longer-lived)
Opaque token issued manually (or via a token endpoint).
Validated by Apache (mod_lua) before proxying to the backend.
For curl/scripts and eventually MCP clients.
Additions to /etc/apache2/sites-enabled/www.critchley.biz-ssl.conf on gravlax.
All required modules already installed (session, session_cookie, session_crypto, auth_form).
# Enable modules (a2enmod)
# session session_cookie session_crypto auth_form
# Session config -- add near top of VirtualHost
Session On
SessionCookieName notes_session path=/private;HttpOnly;Secure;SameSite=Lax
SessionMaxAge 604800
SessionCryptoPassphrase "<random-secret-generate-with-openssl-rand-hex-32>"
# Login handler
<Location /private/login>
SetHandler form-login-handler
AuthType form
AuthName "Private Notes"
AuthFormProvider file
AuthUserFile /etc/apache2/.htpasswd-webdav
AuthFormLoginSuccessLocation /private/html/CONTENTS
Require valid-user
</Location>
# Logout
<Location /private/logout>
SetHandler form-logout-handler
AuthFormLogoutLocation /private/login
</Location>
# Protected HTML renderer -- proxies to private gdata-server
<Location /private/html>
AuthType form
AuthName "Private Notes"
AuthFormProvider file
AuthUserFile /etc/apache2/.htpasswd-webdav
AuthFormLoginRequiredLocation /private/login
Require valid-user
ProxyPass http://localhost:8120/
ProxyPassReverse http://localhost:8120/
</Location>
A simple static HTML file served at GET /private/login. mod_auth_form requires specific field names for the POST:
<form method="POST" action="/private/login">
<input type="text" name="httpd_username" placeholder="Username">
<input type="password" name="httpd_password" placeholder="Password">
<input type="hidden" name="httpd_location" value="">
<button type="submit">Login</button>
</form>
Serve as a static file via Apache (Alias /private/login/form /var/www/html/private-login.html) or inline via the SetHandler. Note: the GET (show form) and POST (handle login) on the same Location both work with mod_auth_form.
In /etc/systemd/system/gdata-mcp-server-private.service, change:
NOTES_WEB=false --> NOTES_WEB=true
Then: sudo systemctl daemon-reload && sudo systemctl restart gdata-mcp-server-private
Currently MCP uses Python OAuth (PKCE) with a per-store password. Replacement plan:
1. Token endpoint: POST /private/token
Accepts Basic auth against .htpasswd-webdav.
Issues a signed opaque token stored server-side (flat file or SQLite).
Token lifetime: configurable (30 days suggested).
2. Apache mod_lua validates Bearer token on MCP requests:
Reads Authorization: Bearer header, looks up in token store, checks expiry.
If valid: proxies to backend. If invalid: 401.
3. Python MCP server drops its own OAuth entirely.
Apache is the auth layer. Python just serves the MCP protocol.
MCP clients get a token from /private/token once and store it in connector settings.
1. Enable NOTES_WEB=true on private notes server (one line change + restart)
2. Add Apache mod_session + mod_auth_form config for /private/html
3. Serve the login form
4. Generate SessionCryptoPassphrase and add to config
5. Enable the modules (a2enmod) and restart Apache
6. Test: browser login -> session cookie -> /private/html/CONTENTS
7. (Later) Bearer token endpoint + mod_lua for MCP
8. (Later) Migrate MCP connectors from OAuth to Bearer token